diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index 9c4721e93..b7adb26f6 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -207,6 +207,8 @@ TokenEscrow LendingProtocol PermissionDelegationV1_1 +MPTokensV2 + # This section can be used to simulate various FeeSettings scenarios for rippled node in standalone mode [voting] reference_fee = 200 # 200 drops diff --git a/CHANGELOG.md b/CHANGELOG.md index ac5da727f..00859adc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [[Unreleased]] +### Added +- Support for XLS-82d MPT-DEX +- The `ledger_entry` RPC can now accept `AMM` input along with the two asset definitions. +- The `MPTCurrency` model has been updated to validate the semantic correctness of `MPTIssuanceID` values. This is performed using regular-expression matching and does not involve any read-operations on the XRPL blockchain. +- The binary-codec of `PathSet` type is updated to accommodate `mpt_issuance_id`. Please refer to XLS-82d MPT-DEX amendment for context behind this change. + ### BREAKING CHANGE - Dropped support for Python 3.8 (EOL October 2024). The minimum supported Python version is now 3.9. diff --git a/tests/integration/it_utils.py b/tests/integration/it_utils.py index 8652f19f5..f5f4fd6d8 100644 --- a/tests/integration/it_utils.py +++ b/tests/integration/it_utils.py @@ -13,15 +13,20 @@ from xrpl.asyncio.transaction import sign_and_submit as sign_and_submit_async from xrpl.clients import Client, JsonRpcClient, WebsocketClient from xrpl.clients.sync_client import SyncClient -from xrpl.constants import CryptoAlgorithm +from xrpl.constants import CryptoAlgorithm, XRPLException from xrpl.models import GenericRequest, Payment, Request, Response, Transaction from xrpl.models.amounts import MPTAmount from xrpl.models.amounts.issued_currency_amount import IssuedCurrencyAmount from xrpl.models.currencies.issued_currency import IssuedCurrency +from xrpl.models.currencies.mpt_currency import MPTCurrency from xrpl.models.currencies.xrp import XRP from xrpl.models.requests import Ledger from xrpl.models.requests.account_objects import AccountObjects, AccountObjectType -from xrpl.models.transactions import MPTokenAuthorize, MPTokenIssuanceCreate +from xrpl.models.transactions import ( + MPTokenAuthorize, + MPTokenIssuanceCreate, + MPTokenIssuanceCreateFlag, +) from xrpl.models.transactions.account_set import AccountSet, AccountSetAsfFlag from xrpl.models.transactions.amm_create import AMMCreate from xrpl.models.transactions.oracle_set import OracleSet @@ -600,6 +605,10 @@ def create_mpt_token_and_authorize_source( ) tx_resp = sign_and_reliable_submission(mp_token_issuance, issuer, client=client) + if tx_resp.result["engine_result"] != "tesSUCCESS": + raise XRPLException( + f"Unable to execute MPTokenIssuanceCreate Transaction: {tx_resp}" + ) seq = tx_resp.result["tx_json"]["Sequence"] response = client.request( @@ -622,7 +631,77 @@ def create_mpt_token_and_authorize_source( account=source.classic_address, mptoken_issuance_id=mpt_issuance_id, ) - sign_and_reliable_submission(authorize_tx, source, client=client) + response = sign_and_reliable_submission(authorize_tx, source, client=client) + if response.result["engine_result"] != "tesSUCCESS": + raise XRPLException( + f"Unable to execute MPTokenAuthorize Transaction: {response}" + ) + + # Send some MPToken to the source wallet that can be used further. + payment_tx = Payment( + account=issuer.address, + destination=source.address, + amount=MPTAmount( + mpt_issuance_id=mpt_issuance_id, + value="100000", + ), + ) + response = sign_and_reliable_submission(payment_tx, issuer, client=client) + if response.result["engine_result"] != "tesSUCCESS": + raise XRPLException(f"Unable to execute Payment Transaction: {response}") + + return mpt_issuance_id + + +async def create_mpt_token_and_authorize_source_async( + issuer: Wallet, + source: Wallet, + client: AsyncClient = ASYNC_JSON_RPC_CLIENT, + flags: Optional[List[int]] = None, +) -> str: + + mp_token_issuance = MPTokenIssuanceCreate( + account=issuer.classic_address, + flags=flags, + ) + + tx_resp = await sign_and_reliable_submission_async( + mp_token_issuance, issuer, client=client + ) + if tx_resp.result["engine_result"] != "tesSUCCESS": + raise XRPLException( + f"Unable to execute MPTokenIssuanceCreate Transaction: {tx_resp}" + ) + + seq = tx_resp.result["tx_json"]["Sequence"] + + response = await client.request( + AccountObjects(account=issuer.address, type=AccountObjectType.MPT_ISSUANCE) + ) + + mpt_issuance_id = "" + for obj in response.result["account_objects"]: + if obj.get("Issuer") == issuer.classic_address and obj.get("Sequence") == seq: + mpt_issuance_id = obj["mpt_issuance_id"] + break + + if not mpt_issuance_id: + raise ValueError( + f"MPT issuance ID not found for issuer " + f"{issuer.classic_address} and sequence {seq}" + ) + + authorize_tx = MPTokenAuthorize( + account=source.classic_address, + mptoken_issuance_id=mpt_issuance_id, + ) + response = await sign_and_reliable_submission_async( + authorize_tx, source, client=client + ) + if response.result["engine_result"] != "tesSUCCESS": + raise XRPLException( + f"Unable to execute MPTokenAuthorize Transaction: {response}" + ) # Send some MPToken to the source wallet that can be used further. payment_tx = Payment( @@ -633,6 +712,146 @@ def create_mpt_token_and_authorize_source( value="100000", ), ) - sign_and_reliable_submission(payment_tx, issuer, client=client) + response = await sign_and_reliable_submission_async( + payment_tx, issuer, client=client + ) + if response.result["engine_result"] != "tesSUCCESS": + raise XRPLException(f"Unable to execute Payment Transaction: {response}") return mpt_issuance_id + + +def create_amm_pool_with_mpt(client: SyncClient = JSON_RPC_CLIENT) -> Dict[str, Any]: + issuer_wallet_1 = Wallet.create() + fund_wallet(issuer_wallet_1) + issuer_wallet_2 = Wallet.create() + fund_wallet(issuer_wallet_2) + lp_wallet = Wallet.create() + fund_wallet(lp_wallet) + + # Create MPT tokens and authorize LP wallet for both + mpt_issuance_id_1 = create_mpt_token_and_authorize_source( + issuer=issuer_wallet_1, + source=lp_wallet, + client=client, + flags=[ + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_CLAWBACK, + ], + ) + mpt_issuance_id_2 = create_mpt_token_and_authorize_source( + issuer=issuer_wallet_2, + source=lp_wallet, + client=client, + flags=[ + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_CLAWBACK, + ], + ) + + # Create the AMM pool with both MPT amounts + response = sign_and_reliable_submission( + AMMCreate( + account=lp_wallet.classic_address, + amount=MPTAmount( + mpt_issuance_id=mpt_issuance_id_1, + value="250", + ), + amount2=MPTAmount( + mpt_issuance_id=mpt_issuance_id_2, + value="250", + ), + trading_fee=12, + ), + lp_wallet, + client, + ) + if ( + not response.is_successful() + or response.result.get("engine_result") != "tesSUCCESS" + ): + raise ValueError( + f"AMMCreate transaction failed: {response.result.get('engine_result')}" + ) + + asset = MPTCurrency(mpt_issuance_id=mpt_issuance_id_1) + asset2 = MPTCurrency(mpt_issuance_id=mpt_issuance_id_2) + + return { + "asset": asset, + "asset2": asset2, + "issuer_wallet_1": issuer_wallet_1, + "issuer_wallet_2": issuer_wallet_2, + "lp_wallet": lp_wallet, + } + + +async def create_amm_pool_with_mpt_async( + client: AsyncClient = ASYNC_JSON_RPC_CLIENT, +) -> Dict[str, Any]: + issuer_wallet_1 = Wallet.create() + await fund_wallet_async(issuer_wallet_1) + issuer_wallet_2 = Wallet.create() + await fund_wallet_async(issuer_wallet_2) + lp_wallet = Wallet.create() + await fund_wallet_async(lp_wallet) + + # Create MPT tokens and authorize LP wallet for both + mpt_issuance_id_1 = await create_mpt_token_and_authorize_source_async( + issuer=issuer_wallet_1, + source=lp_wallet, + client=client, + flags=[ + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_CLAWBACK, + ], + ) + mpt_issuance_id_2 = await create_mpt_token_and_authorize_source_async( + issuer=issuer_wallet_2, + source=lp_wallet, + client=client, + flags=[ + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_CLAWBACK, + ], + ) + + # Create the AMM pool with both MPT amounts + response = await sign_and_reliable_submission_async( + AMMCreate( + account=lp_wallet.classic_address, + amount=MPTAmount( + mpt_issuance_id=mpt_issuance_id_1, + value="250", + ), + amount2=MPTAmount( + mpt_issuance_id=mpt_issuance_id_2, + value="250", + ), + trading_fee=12, + ), + lp_wallet, + client, + ) + if ( + not response.is_successful() + or response.result.get("engine_result") != "tesSUCCESS" + ): + raise ValueError( + f"AMMCreate transaction failed: {response.result.get('engine_result')}" + ) + + asset = MPTCurrency(mpt_issuance_id=mpt_issuance_id_1) + asset2 = MPTCurrency(mpt_issuance_id=mpt_issuance_id_2) + + return { + "asset": asset, + "asset2": asset2, + "issuer_wallet_1": issuer_wallet_1, + "issuer_wallet_2": issuer_wallet_2, + "lp_wallet": lp_wallet, + } diff --git a/tests/integration/reqs/test_amm_info.py b/tests/integration/reqs/test_amm_info.py index d64ed5eb3..701039633 100644 --- a/tests/integration/reqs/test_amm_info.py +++ b/tests/integration/reqs/test_amm_info.py @@ -1,5 +1,8 @@ from tests.integration.integration_test_case import IntegrationTestCase -from tests.integration.it_utils import test_async_and_sync +from tests.integration.it_utils import ( + create_amm_pool_with_mpt_async, + test_async_and_sync, +) from tests.integration.reusable_values import AMM_ASSET, AMM_ASSET2 from xrpl.models.requests.amm_info import AMMInfo @@ -26,3 +29,35 @@ async def test_basic_functionality(self, client): "value": "250", }, ) + + @test_async_and_sync(globals()) + async def test_amm_info_with_mpt_assets(self, client): + amm_pool = await create_amm_pool_with_mpt_async(client) + mpt_asset = amm_pool["asset"] + mpt_asset2 = amm_pool["asset2"] + + amm_info = await client.request( + AMMInfo( + asset=mpt_asset, + asset2=mpt_asset2, + ) + ) + + amm = amm_info.result["amm"] + + self.assertEqual( + amm["amount"], + { + "mpt_issuance_id": mpt_asset.mpt_issuance_id, + "value": "250", + }, + ) + self.assertEqual( + amm["amount2"], + { + "mpt_issuance_id": mpt_asset2.mpt_issuance_id, + "value": "250", + }, + ) + self.assertEqual(amm["trading_fee"], 12) + self.assertIn("lp_token", amm) diff --git a/tests/integration/reqs/test_book_offers.py b/tests/integration/reqs/test_book_offers.py index bd97a8c26..c85f858c0 100644 --- a/tests/integration/reqs/test_book_offers.py +++ b/tests/integration/reqs/test_book_offers.py @@ -1,8 +1,17 @@ from tests.integration.integration_test_case import IntegrationTestCase -from tests.integration.it_utils import test_async_and_sync +from tests.integration.it_utils import ( + create_mpt_token_and_authorize_source_async, + fund_wallet_async, + sign_and_reliable_submission_async, + test_async_and_sync, +) from tests.integration.reusable_values import WALLET +from xrpl.models.amounts import MPTAmount from xrpl.models.currencies import XRP, IssuedCurrency +from xrpl.models.currencies.mpt_currency import MPTCurrency from xrpl.models.requests import BookOffers +from xrpl.models.transactions import MPTokenIssuanceCreateFlag, OfferCreate +from xrpl.wallet import Wallet class TestBookOffers(IntegrationTestCase): @@ -20,3 +29,54 @@ async def test_basic_functionality(self, client): ), ) self.assertTrue(response.is_successful()) + + @test_async_and_sync(globals()) + async def test_book_offers_with_mpt(self, client): + issuer = Wallet.create() + await fund_wallet_async(issuer) + source = Wallet.create() + await fund_wallet_async(source) + + mpt_issuance_id = await create_mpt_token_and_authorize_source_async( + issuer=issuer, + source=source, + client=client, + flags=[ + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + ], + ) + + # Create an offer: source sells MPT for XRP + taker_gets = MPTAmount(mpt_issuance_id=mpt_issuance_id, value="10") + offer_response = await sign_and_reliable_submission_async( + OfferCreate( + account=source.classic_address, + taker_gets=taker_gets, + taker_pays="100000", + ), + source, + client, + ) + self.assertEqual(offer_response.result["engine_result"], "tesSUCCESS") + + # Query book_offers for the MPT/XRP order book + mpt_currency = MPTCurrency(mpt_issuance_id=mpt_issuance_id) + response = await client.request( + BookOffers( + taker_gets=mpt_currency, + taker_pays=XRP(), + ) + ) + self.assertTrue(response.is_successful()) + + offers = response.result["offers"] + self.assertGreaterEqual(len(offers), 1) + + matching = [o for o in offers if o["Account"] == source.classic_address] + self.assertEqual(len(matching), 1) + self.assertEqual( + matching[0]["TakerGets"], + {"mpt_issuance_id": mpt_issuance_id, "value": "10"}, + ) + self.assertEqual(matching[0]["TakerPays"], "100000") diff --git a/tests/integration/reqs/test_ledger_entry.py b/tests/integration/reqs/test_ledger_entry.py new file mode 100644 index 000000000..afc99a663 --- /dev/null +++ b/tests/integration/reqs/test_ledger_entry.py @@ -0,0 +1,35 @@ +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + create_amm_pool_with_mpt_async, + test_async_and_sync, +) +from xrpl.models.requests.ledger_entry import AMM, LedgerEntry + + +class TestLedgerEntry(IntegrationTestCase): + @test_async_and_sync(globals()) + async def test_ledger_entry_amm_with_mpt_assets(self, client): + amm_pool = await create_amm_pool_with_mpt_async(client) + mpt_asset = amm_pool["asset"] + mpt_asset2 = amm_pool["asset2"] + + response = await client.request( + LedgerEntry( + amm=AMM(asset=mpt_asset, asset2=mpt_asset2), + ) + ) + self.assertTrue(response.is_successful()) + + node = response.result["node"] + self.assertEqual(node["LedgerEntryType"], "AMM") + self.assertEqual( + node["Asset"], + {"mpt_issuance_id": mpt_asset.mpt_issuance_id}, + ) + self.assertEqual( + node["Asset2"], + {"mpt_issuance_id": mpt_asset2.mpt_issuance_id}, + ) + self.assertEqual(node["TradingFee"], 12) + self.assertIn("LPTokenBalance", node) + self.assertIn("Account", node) diff --git a/tests/integration/reqs/test_path_find.py b/tests/integration/reqs/test_path_find.py new file mode 100644 index 000000000..0611357da --- /dev/null +++ b/tests/integration/reqs/test_path_find.py @@ -0,0 +1,93 @@ +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + create_mpt_token_and_authorize_source_async, + fund_wallet_async, + sign_and_reliable_submission_async, + test_async_and_sync, +) +from xrpl.models.amounts import MPTAmount +from xrpl.models.requests.path_find import PathFind, PathFindSubcommand +from xrpl.models.transactions import ( + MPTokenAuthorize, + MPTokenIssuanceCreateFlag, + OfferCreate, +) +from xrpl.wallet import Wallet + + +class TestPathFind(IntegrationTestCase): + @test_async_and_sync(globals(), websockets_only=True) + async def test_path_find_with_mpt(self, client): + issuer = Wallet.create() + await fund_wallet_async(issuer) + source = Wallet.create() + await fund_wallet_async(source) + destination = Wallet.create() + await fund_wallet_async(destination) + + mpt_issuance_id = await create_mpt_token_and_authorize_source_async( + issuer=issuer, + source=source, + client=client, + flags=[ + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE, + ], + ) + + # Authorize destination to hold this MPT + await sign_and_reliable_submission_async( + MPTokenAuthorize( + account=destination.classic_address, + mptoken_issuance_id=mpt_issuance_id, + ), + destination, + client, + ) + + # Create an offer on the DEX: source sells MPT for XRP + await sign_and_reliable_submission_async( + OfferCreate( + account=source.classic_address, + taker_gets=MPTAmount( + mpt_issuance_id=mpt_issuance_id, + value="100", + ), + taker_pays="1000000", + ), + source, + client, + ) + + # Create the path_find request + response = await client.request( + PathFind( + subcommand=PathFindSubcommand.CREATE, + source_account=source.classic_address, + destination_account=destination.classic_address, + destination_amount=MPTAmount( + mpt_issuance_id=mpt_issuance_id, + value="100", + ), + ) + ) + self.assertTrue(response.is_successful()) + self.assertEqual( + response.result["destination_amount"], + {"mpt_issuance_id": mpt_issuance_id, "value": "100"}, + ) + self.assertGreater(len(response.result["alternatives"]), 0) + + # Close the path_find subscription + close_response = await client.request( + PathFind( + subcommand=PathFindSubcommand.CLOSE, + source_account=source.classic_address, + destination_account=destination.classic_address, + destination_amount=MPTAmount( + mpt_issuance_id=mpt_issuance_id, + value="100", + ), + ) + ) + self.assertTrue(close_response.is_successful()) diff --git a/tests/integration/reqs/test_ripple_path_find.py b/tests/integration/reqs/test_ripple_path_find.py index 8bbd4f61c..ab58bc956 100644 --- a/tests/integration/reqs/test_ripple_path_find.py +++ b/tests/integration/reqs/test_ripple_path_find.py @@ -1,7 +1,19 @@ from tests.integration.integration_test_case import IntegrationTestCase -from tests.integration.it_utils import test_async_and_sync +from tests.integration.it_utils import ( + create_mpt_token_and_authorize_source_async, + fund_wallet_async, + sign_and_reliable_submission_async, + test_async_and_sync, +) from tests.integration.reusable_values import DESTINATION, WALLET +from xrpl.models.amounts import MPTAmount from xrpl.models.requests import RipplePathFind +from xrpl.models.transactions import ( + MPTokenAuthorize, + MPTokenIssuanceCreateFlag, + OfferCreate, +) +from xrpl.wallet import Wallet class TestRipplePathFind(IntegrationTestCase): @@ -15,3 +27,63 @@ async def test_basic_functionality(self, client): ), ) self.assertTrue(response.is_successful()) + + @test_async_and_sync(globals()) + async def test_ripple_path_find_with_mpt(self, client): + issuer = Wallet.create() + await fund_wallet_async(issuer) + source = Wallet.create() + await fund_wallet_async(source) + destination = Wallet.create() + await fund_wallet_async(destination) + + mpt_issuance_id = await create_mpt_token_and_authorize_source_async( + issuer=issuer, + source=source, + client=client, + flags=[ + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE, + ], + ) + + # Authorize destination to hold this MPT + await sign_and_reliable_submission_async( + MPTokenAuthorize( + account=destination.classic_address, + mptoken_issuance_id=mpt_issuance_id, + ), + destination, + client, + ) + + # Create an offer on the DEX: source sells MPT for XRP + await sign_and_reliable_submission_async( + OfferCreate( + account=source.classic_address, + taker_gets=MPTAmount( + mpt_issuance_id=mpt_issuance_id, + value="100", + ), + taker_pays="1000000", + ), + source, + client, + ) + + response = await client.request( + RipplePathFind( + source_account=source.classic_address, + destination_account=destination.classic_address, + destination_amount=MPTAmount( + mpt_issuance_id=mpt_issuance_id, + value="100", + ), + ), + ) + self.assertTrue(response.is_successful()) + self.assertEqual( + response.result["destination_amount"], + {"mpt_issuance_id": mpt_issuance_id, "value": "100"}, + ) + self.assertGreater(len(response.result["alternatives"]), 0) diff --git a/tests/integration/transactions/test_amm_lifecycle_with_mpt.py b/tests/integration/transactions/test_amm_lifecycle_with_mpt.py new file mode 100644 index 000000000..7a326678b --- /dev/null +++ b/tests/integration/transactions/test_amm_lifecycle_with_mpt.py @@ -0,0 +1,232 @@ +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + create_amm_pool_with_mpt_async, + sign_and_reliable_submission_async, + test_async_and_sync, +) +from xrpl.models.amounts import MPTAmount +from xrpl.models.requests.amm_info import AMMInfo +from xrpl.models.transactions.amm_clawback import AMMClawback +from xrpl.models.transactions.amm_deposit import AMMDeposit, AMMDepositFlag +from xrpl.models.transactions.amm_withdraw import AMMWithdraw, AMMWithdrawFlag + + +class TestAMMLifecycleWithMPT(IntegrationTestCase): + @test_async_and_sync(globals()) + async def test_mpt_amm_pool(self, client): + amm_pool = await create_amm_pool_with_mpt_async(client) + asset = amm_pool["asset"] + asset2 = amm_pool["asset2"] + + amm_info = await client.request( + AMMInfo( + asset=asset, + asset2=asset2, + ) + ) + + amm = amm_info.result["amm"] + + self.assertEqual( + amm["amount"], + { + "mpt_issuance_id": asset.mpt_issuance_id, + "value": "250", + }, + ) + self.assertEqual( + amm["amount2"], + { + "mpt_issuance_id": asset2.mpt_issuance_id, + "value": "250", + }, + ) + self.assertEqual(amm["trading_fee"], 12) + + @test_async_and_sync(globals()) + async def test_mpt_amm_deposit_single_asset(self, client): + amm_pool = await create_amm_pool_with_mpt_async(client) + asset = amm_pool["asset"] + asset2 = amm_pool["asset2"] + lp_wallet = amm_pool["lp_wallet"] + + pre_amm_info = await client.request( + AMMInfo( + asset=asset, + asset2=asset2, + ) + ) + pre_amm = pre_amm_info.result["amm"] + before_amount = int(pre_amm["amount"]["value"]) + before_amount2 = int(pre_amm["amount2"]["value"]) + before_lp_token_value = float(pre_amm["lp_token"]["value"]) + + deposit_value = "100" + response = await sign_and_reliable_submission_async( + AMMDeposit( + account=lp_wallet.classic_address, + asset=asset, + asset2=asset2, + amount=MPTAmount( + mpt_issuance_id=asset.mpt_issuance_id, + value=deposit_value, + ), + flags=AMMDepositFlag.TF_SINGLE_ASSET, + ), + lp_wallet, + client, + ) + + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + post_amm_info = await client.request( + AMMInfo( + asset=asset, + asset2=asset2, + ) + ) + post_amm = post_amm_info.result["amm"] + + # The deposited asset's pool balance should increase by the deposit amount + self.assertEqual( + int(post_amm["amount"]["value"]), + before_amount + int(deposit_value), + ) + # The other asset's pool balance should remain unchanged + self.assertEqual(int(post_amm["amount2"]["value"]), before_amount2) + # LP token supply should increase after the deposit + self.assertGreater( + float(post_amm["lp_token"]["value"]), + before_lp_token_value, + ) + + @test_async_and_sync(globals()) + async def test_mpt_amm_withdraw_single_asset(self, client): + amm_pool = await create_amm_pool_with_mpt_async(client) + asset = amm_pool["asset"] + asset2 = amm_pool["asset2"] + lp_wallet = amm_pool["lp_wallet"] + + pre_amm_info = await client.request(AMMInfo(asset=asset, asset2=asset2)) + pre_amm = pre_amm_info.result["amm"] + before_amount = int(pre_amm["amount"]["value"]) + before_amount2 = int(pre_amm["amount2"]["value"]) + before_lp_token_value = float(pre_amm["lp_token"]["value"]) + + withdraw_value = "50" + response = await sign_and_reliable_submission_async( + AMMWithdraw( + account=lp_wallet.classic_address, + asset=asset, + asset2=asset2, + amount=MPTAmount( + mpt_issuance_id=asset.mpt_issuance_id, + value=withdraw_value, + ), + flags=AMMWithdrawFlag.TF_SINGLE_ASSET, + ), + lp_wallet, + client, + ) + + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + post_amm_info = await client.request(AMMInfo(asset=asset, asset2=asset2)) + post_amm = post_amm_info.result["amm"] + + # The withdrawn asset's pool balance should decrease by the withdraw amount + self.assertEqual( + int(post_amm["amount"]["value"]), + before_amount - int(withdraw_value), + ) + # The other asset's pool balance should remain unchanged + self.assertEqual(int(post_amm["amount2"]["value"]), before_amount2) + # LP token supply should decrease after the withdrawal + self.assertLess( + float(post_amm["lp_token"]["value"]), + before_lp_token_value, + ) + + @test_async_and_sync(globals()) + async def test_mpt_amm_delete(self, client): + amm_pool = await create_amm_pool_with_mpt_async(client) + asset = amm_pool["asset"] + asset2 = amm_pool["asset2"] + lp_wallet = amm_pool["lp_wallet"] + + # Withdraw all assets to empty the pool + # Note: Withdrawal of all the assets (i.e outstanding LPTokens = 0) in the AMM + # pool will delete the AMM. + response = await sign_and_reliable_submission_async( + AMMWithdraw( + account=lp_wallet.classic_address, + asset=asset, + asset2=asset2, + flags=AMMWithdrawFlag.TF_WITHDRAW_ALL, + ), + lp_wallet, + client, + ) + + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Verify the AMM no longer exists + amm_info = await client.request(AMMInfo(asset=asset, asset2=asset2)) + self.assertEqual(amm_info.result["error"], "actNotFound") + + @test_async_and_sync(globals()) + async def test_mpt_amm_clawback(self, client): + amm_pool = await create_amm_pool_with_mpt_async( + client, + ) + asset = amm_pool["asset"] + asset2 = amm_pool["asset2"] + issuer_wallet = amm_pool["issuer_wallet_1"] + holder_wallet = amm_pool["lp_wallet"] + + # Holder deposits more of asset into the AMM + await sign_and_reliable_submission_async( + AMMDeposit( + account=holder_wallet.classic_address, + asset=asset, + asset2=asset2, + amount=MPTAmount( + mpt_issuance_id=asset.mpt_issuance_id, + value="10", + ), + flags=AMMDepositFlag.TF_SINGLE_ASSET, + ), + holder_wallet, + client, + ) + + pre_amm_info = await client.request(AMMInfo(asset=asset, asset2=asset2)) + pre_amm = pre_amm_info.result["amm"] + before_amount = int(pre_amm["amount"]["value"]) + + # Issuer claws back holder's share of asset from the AMM + response = await sign_and_reliable_submission_async( + AMMClawback( + account=issuer_wallet.classic_address, + holder=holder_wallet.classic_address, + asset=asset, + asset2=asset2, + amount=MPTAmount( + mpt_issuance_id=asset.mpt_issuance_id, + value="10", + ), + ), + issuer_wallet, + client, + ) + + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Pool's balance of the clawed-back asset should decrease + post_amm_info = await client.request(AMMInfo(asset=asset, asset2=asset2)) + post_amm = post_amm_info.result["amm"] + self.assertLess(int(post_amm["amount"]["value"]), before_amount) diff --git a/tests/integration/transactions/test_check_cash.py b/tests/integration/transactions/test_check_cash.py index 0d1c03b96..ded33fe7b 100644 --- a/tests/integration/transactions/test_check_cash.py +++ b/tests/integration/transactions/test_check_cash.py @@ -1,11 +1,16 @@ from tests.integration.integration_test_case import IntegrationTestCase from tests.integration.it_utils import ( + create_mpt_token_and_authorize_source_async, + fund_wallet_async, sign_and_reliable_submission_async, test_async_and_sync, ) from tests.integration.reusable_values import WALLET +from xrpl.models.amounts import MPTAmount +from xrpl.models.requests.account_objects import AccountObjects, AccountObjectType from xrpl.models.response import ResponseStatus -from xrpl.models.transactions import CheckCash +from xrpl.models.transactions import CheckCash, CheckCreate, MPTokenIssuanceCreateFlag +from xrpl.wallet import Wallet ACCOUNT = WALLET.address CHECK_ID = "838766BA2B995C00744175F69A1B11E32C3DBC40E64801A4056FCBD657F57334" @@ -13,7 +18,7 @@ DELIVER_MIN = "100000000" -class TestCheckCreate(IntegrationTestCase): +class TestCheckCash(IntegrationTestCase): @test_async_and_sync(globals()) async def test_required_fields_with_amount(self, client): check_cash = CheckCash( @@ -36,3 +41,65 @@ async def test_required_fields_with_deliver_min(self, client): response = await sign_and_reliable_submission_async(check_cash, WALLET, client) self.assertEqual(response.status, ResponseStatus.SUCCESS) self.assertEqual(response.result["engine_result"], "tecNO_ENTRY") + + @test_async_and_sync(globals()) + async def test_check_cash_with_mpt(self, client): + issuer = Wallet.create() + await fund_wallet_async(issuer) + destination_check_wallet = Wallet.create() + await fund_wallet_async(destination_check_wallet) + + mpt_issuance_id = await create_mpt_token_and_authorize_source_async( + issuer=issuer, + source=destination_check_wallet, + client=client, + flags=[ + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE, + ], + ) + + # Issuer creates a check to destination for 50 MPT + mpt_amount = MPTAmount(mpt_issuance_id=mpt_issuance_id, value="50") + create_response = await sign_and_reliable_submission_async( + CheckCreate( + account=issuer.classic_address, + destination=destination_check_wallet.classic_address, + send_max=mpt_amount, + ), + issuer, + client, + ) + self.assertEqual(create_response.result["engine_result"], "tesSUCCESS") + + # Find the check ID + account_objects_response = await client.request( + AccountObjects( + account=destination_check_wallet.classic_address, + type=AccountObjectType.CHECK, + ) + ) + checks = account_objects_response.result["account_objects"] + self.assertEqual(len(checks), 1) + mpt_check_id = checks[0]["index"] + + # Destination cashes the check + cash_response = await sign_and_reliable_submission_async( + CheckCash( + account=destination_check_wallet.classic_address, + check_id=mpt_check_id, + amount=mpt_amount, + ), + destination_check_wallet, + client, + ) + self.assertEqual(cash_response.result["engine_result"], "tesSUCCESS") + + # Verify the check was consumed + account_objects_response = await client.request( + AccountObjects( + account=destination_check_wallet.classic_address, + type=AccountObjectType.CHECK, + ) + ) + self.assertEqual(len(account_objects_response.result["account_objects"]), 0) diff --git a/tests/integration/transactions/test_check_create.py b/tests/integration/transactions/test_check_create.py index 9ac11284f..d66986fa6 100644 --- a/tests/integration/transactions/test_check_create.py +++ b/tests/integration/transactions/test_check_create.py @@ -1,11 +1,17 @@ from tests.integration.integration_test_case import IntegrationTestCase from tests.integration.it_utils import ( + create_mpt_token_and_authorize_source_async, + fund_wallet_async, sign_and_reliable_submission_async, test_async_and_sync, ) from tests.integration.reusable_values import DESTINATION, WALLET +from xrpl.models.amounts import MPTAmount +from xrpl.models.requests.account_objects import AccountObjects, AccountObjectType +from xrpl.models.requests.ledger_entry import LedgerEntry from xrpl.models.response import ResponseStatus -from xrpl.models.transactions import CheckCreate +from xrpl.models.transactions import CheckCreate, MPTokenIssuanceCreateFlag +from xrpl.wallet import Wallet ACCOUNT = WALLET.address DESTINATION_TAG = 1 @@ -30,3 +36,57 @@ async def test_all_fields(self, client): ) self.assertEqual(response.status, ResponseStatus.SUCCESS) self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + @test_async_and_sync(globals()) + async def test_use_MPT_with_Check(self, client): + issuer = Wallet.create() + await fund_wallet_async(issuer) + check_destination_wallet = Wallet.create() + await fund_wallet_async(check_destination_wallet) + + mpt_issuance_id = await create_mpt_token_and_authorize_source_async( + issuer=issuer, + source=check_destination_wallet, + client=client, + flags=[ + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE, + ], + ) + + send_max = MPTAmount(mpt_issuance_id=mpt_issuance_id, value="50") + response = await sign_and_reliable_submission_async( + CheckCreate( + account=issuer.classic_address, + destination=check_destination_wallet.classic_address, + send_max=send_max, + ), + issuer, + client, + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Find the check object ID via account_objects + account_objects_response = await client.request( + AccountObjects( + account=check_destination_wallet.classic_address, + type=AccountObjectType.CHECK, + ) + ) + checks = account_objects_response.result["account_objects"] + self.assertEqual(len(checks), 1) + check_index = checks[0]["index"] + + # Validate the check using ledger_entry + ledger_entry_response = await client.request(LedgerEntry(check=check_index)) + check_node = ledger_entry_response.result["node"] + self.assertEqual(check_node["LedgerEntryType"], "Check") + self.assertEqual(check_node["Account"], issuer.classic_address) + self.assertEqual( + check_node["Destination"], check_destination_wallet.classic_address + ) + self.assertEqual( + check_node["SendMax"], + {"mpt_issuance_id": mpt_issuance_id, "value": "50"}, + ) diff --git a/tests/integration/transactions/test_offer_create.py b/tests/integration/transactions/test_offer_create.py index 288626a16..90dfb8544 100644 --- a/tests/integration/transactions/test_offer_create.py +++ b/tests/integration/transactions/test_offer_create.py @@ -1,12 +1,19 @@ from tests.integration.integration_test_case import IntegrationTestCase from tests.integration.it_utils import ( + create_mpt_token_and_authorize_source_async, fund_wallet_async, sign_and_reliable_submission_async, test_async_and_sync, ) from tests.integration.reusable_values import WALLET -from xrpl.models.amounts import IssuedCurrencyAmount -from xrpl.models.transactions import OfferCreate, TrustSet, TrustSetFlag +from xrpl.models.amounts import IssuedCurrencyAmount, MPTAmount +from xrpl.models.requests.ledger_entry import LedgerEntry, Offer +from xrpl.models.transactions import ( + MPTokenIssuanceCreateFlag, + OfferCreate, + TrustSet, + TrustSetFlag, +) from xrpl.wallet import Wallet @@ -28,6 +35,51 @@ async def test_basic_functionality(self, client): ) self.assertTrue(offer.is_successful()) + @test_async_and_sync(globals()) + async def test_offer_create_with_MPT(self, client): + issuer = Wallet.create() + await fund_wallet_async(issuer) + source = Wallet.create() + await fund_wallet_async(source) + + mpt_issuance_id = await create_mpt_token_and_authorize_source_async( + issuer=issuer, + source=source, + client=client, + flags=[ + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + ], + ) + + taker_gets = MPTAmount(mpt_issuance_id=mpt_issuance_id, value="10") + response = await sign_and_reliable_submission_async( + OfferCreate( + account=source.classic_address, + taker_gets=taker_gets, + taker_pays="100000", + ), + source, + client, + ) + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + offer_seq = response.result["tx_json"]["Sequence"] + + # Validate the offer using ledger_entry + ledger_entry_response = await client.request( + LedgerEntry(offer=Offer(account=source.classic_address, seq=offer_seq)) + ) + offer_node = ledger_entry_response.result["node"] + self.assertEqual(offer_node["LedgerEntryType"], "Offer") + self.assertEqual(offer_node["Account"], source.classic_address) + self.assertEqual( + offer_node["TakerGets"], + {"mpt_issuance_id": mpt_issuance_id, "value": "10"}, + ) + self.assertEqual(offer_node["TakerPays"], "100000") + @test_async_and_sync(globals()) async def test_deep_freeze_trustline_fails(self, client): diff --git a/tests/integration/transactions/test_payment.py b/tests/integration/transactions/test_payment.py index 561e42ded..260316812 100644 --- a/tests/integration/transactions/test_payment.py +++ b/tests/integration/transactions/test_payment.py @@ -1,14 +1,25 @@ from tests.integration.integration_test_case import IntegrationTestCase from tests.integration.it_utils import ( + create_mpt_token_and_authorize_source_async, + fund_wallet_async, sign_and_reliable_submission_async, test_async_and_sync, ) from tests.integration.reusable_values import DESTINATION, WALLET from xrpl.models.amounts.mpt_amount import MPTAmount from xrpl.models.exceptions import XRPLModelException +from xrpl.models.path import PathStep from xrpl.models.requests.account_objects import AccountObjects, AccountObjectType +from xrpl.models.requests.tx import Tx from xrpl.models.transactions import Payment -from xrpl.models.transactions.mptoken_issuance_create import MPTokenIssuanceCreate +from xrpl.models.transactions.amm_create import AMMCreate +from xrpl.models.transactions.mptoken_authorize import MPTokenAuthorize +from xrpl.models.transactions.mptoken_issuance_create import ( + MPTokenIssuanceCreate, + MPTokenIssuanceCreateFlag, +) +from xrpl.models.transactions.payment import PaymentFlag +from xrpl.wallet import Wallet class TestPayment(IntegrationTestCase): @@ -179,3 +190,194 @@ async def test_mpt_payment(self, client): client, ) self.assertTrue(response.is_successful()) + + @test_async_and_sync(globals()) + async def test_payment_with_mpt_pathset(self, client): + issuer = Wallet.create() + await fund_wallet_async(issuer) + lp_wallet = Wallet.create() + await fund_wallet_async(lp_wallet) + destination = Wallet.create() + await fund_wallet_async(destination) + + mpt_flags = [ + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + ] + + # Create MPT, authorize + fund LP wallet + mpt_id = await create_mpt_token_and_authorize_source_async( + issuer=issuer, + source=lp_wallet, + client=client, + flags=mpt_flags, + ) + + # Authorize destination to hold MPT + auth_dest = MPTokenAuthorize( + account=destination.classic_address, + mptoken_issuance_id=mpt_id, + ) + response = await sign_and_reliable_submission_async( + auth_dest, destination, client + ) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Create AMM pool: XRP / MPT + amm_create = AMMCreate( + account=lp_wallet.classic_address, + amount="1000000", + amount2=MPTAmount(mpt_issuance_id=mpt_id, value="1000"), + trading_fee=12, + ) + response = await sign_and_reliable_submission_async( + amm_create, lp_wallet, client + ) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Authorize sender to hold MPT + auth_sender = MPTokenAuthorize( + account=WALLET.classic_address, + mptoken_issuance_id=mpt_id, + ) + response = await sign_and_reliable_submission_async(auth_sender, WALLET, client) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Cross-currency payment: XRP → MPT via XRP/MPT AMM pool + pay_tx = Payment( + account=WALLET.address, + destination=destination.address, + amount=MPTAmount(mpt_issuance_id=mpt_id, value="5"), + send_max="500000", + # Explicitly specify a Path with mpt_issuance_id intermediate Hop + paths=[[PathStep(mpt_issuance_id=mpt_id)]], + ) + response = await sign_and_reliable_submission_async(pay_tx, WALLET, client) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + @test_async_and_sync(globals()) + async def test_mpt_payment_delivered_amount(self, client): + """Verify delivered_amount in metadata is an MPT + for direct MPT payments.""" + issuer = Wallet.create() + await fund_wallet_async(issuer) + holder = Wallet.create() + await fund_wallet_async(holder) + + mpt_flags = [ + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + ] + + mpt_issuance_id = await create_mpt_token_and_authorize_source_async( + issuer=issuer, + source=holder, + client=client, + flags=mpt_flags, + ) + + # Send MPT from holder to a new authorized recipient + recipient = Wallet.create() + await fund_wallet_async(recipient) + auth_tx = MPTokenAuthorize( + account=recipient.classic_address, + mptoken_issuance_id=mpt_issuance_id, + ) + response = await sign_and_reliable_submission_async(auth_tx, recipient, client) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + payment_amount = "500" + payment = Payment( + account=holder.address, + amount=MPTAmount( + mpt_issuance_id=mpt_issuance_id, + value=payment_amount, + ), + destination=recipient.address, + ) + + response = await sign_and_reliable_submission_async( + payment, + holder, + client, + ) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Fetch full transaction to get metadata + tx_hash = response.result["tx_json"]["hash"] + tx_response = await client.request(Tx(transaction=tx_hash)) + meta = tx_response.result["meta"] + + self.assertIn("delivered_amount", meta) + delivered = meta["delivered_amount"] + self.assertEqual(delivered["mpt_issuance_id"], mpt_issuance_id) + self.assertEqual(delivered["value"], payment_amount) + + @test_async_and_sync(globals()) + async def test_mpt_partial_payment_delivered_amount(self, client): + """Verify delivered_amount < Amount for a partial + MPT payment.""" + issuer = Wallet.create() + await fund_wallet_async(issuer) + holder = Wallet.create() + await fund_wallet_async(holder) + + mpt_flags = [ + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRADE, + MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + ] + + # Fund holder with 500 MPT + mpt_issuance_id = await create_mpt_token_and_authorize_source_async( + issuer=issuer, + source=holder, + client=client, + flags=mpt_flags, + ) + + recipient = Wallet.create() + await fund_wallet_async(recipient) + auth_tx = MPTokenAuthorize( + account=recipient.classic_address, + mptoken_issuance_id=mpt_issuance_id, + ) + response = await sign_and_reliable_submission_async(auth_tx, recipient, client) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Partial payment: request 1000 MPT but cap SendMax + # at 500 MPT (holder's entire balance) + requested_amount = "1000" + send_max_amount = "500" + payment = Payment( + account=holder.address, + destination=recipient.address, + amount=MPTAmount( + mpt_issuance_id=mpt_issuance_id, + value=requested_amount, + ), + send_max=MPTAmount( + mpt_issuance_id=mpt_issuance_id, + value=send_max_amount, + ), + flags=[PaymentFlag.TF_PARTIAL_PAYMENT], + ) + + response = await sign_and_reliable_submission_async( + payment, + holder, + client, + ) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Fetch full transaction to get metadata + tx_hash = response.result["tx_json"]["hash"] + tx_response = await client.request(Tx(transaction=tx_hash)) + meta = tx_response.result["meta"] + + self.assertIn("delivered_amount", meta) + delivered = meta["delivered_amount"] + self.assertEqual(delivered["mpt_issuance_id"], mpt_issuance_id) + # delivered_amount should be <= send_max (500), + # and less than the requested Amount (1000) + self.assertEqual(int(delivered["value"]), int(send_max_amount)) + self.assertLess(int(delivered["value"]), int(requested_amount)) diff --git a/tests/unit/core/binarycodec/types/test_path_set.py b/tests/unit/core/binarycodec/types/test_path_set.py index 568879f6d..ae71150e0 100644 --- a/tests/unit/core/binarycodec/types/test_path_set.py +++ b/tests/unit/core/binarycodec/types/test_path_set.py @@ -2,6 +2,8 @@ from xrpl.core.binarycodec import XRPLBinaryCodecException from xrpl.core.binarycodec.binary_wrappers.binary_parser import BinaryParser +from xrpl.core.binarycodec.types.account_id import AccountID +from xrpl.core.binarycodec.types.currency import Currency from xrpl.core.binarycodec.types.path_set import PathSet buffer = ( @@ -108,3 +110,130 @@ def test_from_parser_to_json(self): def test_raises_invalid_value_type(self): invalid_value = 1 self.assertRaises(XRPLBinaryCodecException, PathSet.from_value, invalid_value) + + # ── MPT PathSet serialization tests ── + # + # PathSet binary format reference (from rippled STPathSet.cpp): + # + # Each path step starts with a 1-byte type flag bitmask: + # 0x01 = account (followed by 20-byte AccountID) + # 0x10 = currency (followed by 20-byte Currency) + # 0x20 = issuer (followed by 20-byte AccountID) + # 0x40 = MPT (followed by 24-byte MPTID) + # + # Special marker bytes: + # 0xFF = path boundary (separates alternative paths within a PathSet) + # 0x00 = end of PathSet (terminates the entire PathSet) + # + # Currency (0x10) and MPT (0x40) are mutually exclusive within a + # single path step — a step cannot carry both flags. + + def test_one_path_with_one_mpt_hop(self): + mpt_issuance_id = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8" + path = [[{"mpt_issuance_id": mpt_issuance_id}]] + + pathset = PathSet.from_value(path) + # "40" → type byte: MPT flag (0x40) + # mpt_issuance_id → raw 24-byte MPTID + # "00" → end of PathSet + expected_hex = "40" + mpt_issuance_id + "00" + self.assertEqual(str(pathset).upper(), expected_hex) + + # round-trip JSON equivalence + self.assertEqual(pathset.to_json(), path) + + # deserialization via BinaryParser + parser = BinaryParser(expected_hex) + self.assertEqual(str(PathSet.from_parser(parser)), str(pathset)) + + def test_two_paths_with_mpt_hops(self): + mpt_id_1 = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8" + mpt_id_2 = "000004C463C52827307480341125DA0577DEFC38405B0E3E" + path = [ + [{"mpt_issuance_id": mpt_id_1}], + [{"mpt_issuance_id": mpt_id_2}], + ] + + pathset = PathSet.from_value(path) + # "40" + mpt_id_1 → first path: one MPT hop + # "FF" → path boundary separating alternative paths + # "40" + mpt_id_2 → second path: one MPT hop + # "00" → end of PathSet + expected_hex = "40" + mpt_id_1 + "FF" + "40" + mpt_id_2 + "00" + self.assertEqual(str(pathset).upper(), expected_hex) + + self.assertEqual(pathset.to_json(), path) + + parser = BinaryParser(expected_hex) + self.assertEqual(str(PathSet.from_parser(parser)), str(pathset)) + + def test_path_with_mpt_and_currency_path_elements(self): + """One path with two distinct steps: an MPT hop followed by a Currency hop.""" + mpt_issuance_id = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8" + currency_code = "ABC" + path = [ + [ + {"mpt_issuance_id": mpt_issuance_id}, + {"currency": currency_code}, + ] + ] + + pathset = PathSet.from_value(path) + currency_hex = str(Currency.from_value(currency_code)).upper() + # "40" + mpt_id → first step: MPT hop (0x40 type flag) + # "10" + currency_hex → second step: Currency hop (0x10 type flag) + # "00" → end of PathSet + expected_hex = "40" + mpt_issuance_id + "10" + currency_hex + "00" + self.assertEqual(str(pathset).upper(), expected_hex) + + self.assertEqual(pathset.to_json(), path) + + parser = BinaryParser(expected_hex) + self.assertEqual(str(PathSet.from_parser(parser)), str(pathset)) + + def test_path_with_mpt_and_issuer_path_elements(self): + """One path with two distinct steps: an MPT hop followed by an Issuer hop.""" + mpt_issuance_id = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8" + issuer_account = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + path = [ + [ + {"mpt_issuance_id": mpt_issuance_id}, + {"issuer": issuer_account}, + ] + ] + + pathset = PathSet.from_value(path) + issuer_hex = str(AccountID.from_value(issuer_account)).upper() + # "40" + mpt_id → first step: MPT hop (0x40 type flag) + # "20" + issuer_hex → second step: Issuer hop (0x20 type flag) + # "00" → end of PathSet + expected_hex = "40" + mpt_issuance_id + "20" + issuer_hex + "00" + self.assertEqual(str(pathset).upper(), expected_hex) + + self.assertEqual(pathset.to_json(), path) + + parser = BinaryParser(expected_hex) + self.assertEqual(str(PathSet.from_parser(parser)), str(pathset)) + + def test_currency_and_mpt_mutually_exclusive_in_serialization(self): + """Providing both currency and mpt_issuance_id in a single step must raise.""" + path = [ + [ + { + "currency": "ABC", + "mpt_issuance_id": "00000001B5F762798A53" + "D543A014CAF8B297CFF8F2F937E8", + } + ] + ] + self.assertRaises(XRPLBinaryCodecException, PathSet.from_value, path) + + def test_currency_and_mpt_mutually_exclusive_in_deserialization(self): + """A type byte with both Currency (0x10) and MPT (0x40) flags must raise.""" + # "50" = 0x10 | 0x40 — an invalid combination + currency_hex = str(Currency.from_value("ABC")).upper() + mpt_hex = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8" + invalid_hex = "50" + currency_hex + mpt_hex + "00" + + parser = BinaryParser(invalid_hex) + self.assertRaises(XRPLBinaryCodecException, PathSet.from_parser, parser) diff --git a/tests/unit/models/test_path.py b/tests/unit/models/test_path.py new file mode 100644 index 000000000..5ef70e3dd --- /dev/null +++ b/tests/unit/models/test_path.py @@ -0,0 +1,61 @@ +from unittest import TestCase + +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.path import PathStep + +_ACCOUNT = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" +_ISSUER = "rpGtkFRXhgVaBzC5XCR7gyE2AZN5SN3SEW" +_MPT_ID = "00000001A407AF5856CECE4281FED12B7B179B49A4AEF506" + + +class TestPathStepMPT(TestCase): + # --- valid cases --- + + def test_valid_mpt_issuance_id_only(self): + step = PathStep(mpt_issuance_id=_MPT_ID) + self.assertTrue(step.is_valid()) + + def test_valid_account_only(self): + step = PathStep(account=_ACCOUNT) + self.assertTrue(step.is_valid()) + + # --- account + mpt_issuance_id --- + + def test_account_with_mpt_issuance_id(self): + with self.assertRaises(XRPLModelException) as ctx: + PathStep(account=_ACCOUNT, mpt_issuance_id=_MPT_ID) + self.assertIn( + "Cannot set account if mpt_issuance_id is specified", + ctx.exception.args[0], + ) + + # --- currency + mpt_issuance_id --- + + def test_currency_with_mpt_issuance_id(self): + with self.assertRaises(XRPLModelException) as ctx: + PathStep(currency="USD", mpt_issuance_id=_MPT_ID) + self.assertIn( + "Cannot set both currency and mpt_issuance_id", + ctx.exception.args[0], + ) + + # --- mpt_issuance_id + currency (from mpt validator) --- + + def test_mpt_issuance_id_with_currency(self): + """Same combo as above, but verifies the mpt_issuance_id validator's message.""" + with self.assertRaises(XRPLModelException) as ctx: + PathStep(mpt_issuance_id=_MPT_ID, currency="USD") + self.assertIn( + "Cannot set both mpt_issuance_id and currency", + ctx.exception.args[0], + ) + + # --- mpt_issuance_id + account (from mpt validator) --- + + def test_mpt_issuance_id_with_account(self): + with self.assertRaises(XRPLModelException) as ctx: + PathStep(mpt_issuance_id=_MPT_ID, account=_ACCOUNT) + self.assertIn( + "Cannot set both mpt_issuance_id and account", + ctx.exception.args[0], + ) diff --git a/tests/unit/models/transactions/test_amm_clawback.py b/tests/unit/models/transactions/test_amm_clawback.py index c4f372e1b..6cf9dd2a3 100644 --- a/tests/unit/models/transactions/test_amm_clawback.py +++ b/tests/unit/models/transactions/test_amm_clawback.py @@ -1,12 +1,16 @@ from unittest import TestCase -from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.constants import MPT_ISSUANCE_ID_LENGTH +from xrpl.models.amounts import IssuedCurrencyAmount, MPTAmount from xrpl.models.currencies import XRP, IssuedCurrency +from xrpl.models.currencies.mpt_currency import MPTCurrency from xrpl.models.exceptions import XRPLModelException from xrpl.models.transactions import AMMClawback from xrpl.models.transactions.amm_clawback import AMMClawbackFlag _ISSUER_ACCOUNT = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" +_MPT_ISSUANCE_ID_1 = "00000001A407AF5856CECE4281FED12B7B179B49A4AEF506" +_MPT_ISSUANCE_ID_2 = "00000002A407AF5856CECE4281FED12B7B179B49A4AEF506" _ASSET2 = XRP() _INVALID_ASSET = IssuedCurrency( currency="ETH", issuer="rpGtkFRXhgVaBzC5XCR7gyE2AZN5SN3SEW" @@ -71,3 +75,118 @@ def test_valid_txn(self): flags=AMMClawbackFlag.TF_CLAW_TWO_ASSETS, ) self.assertTrue(txn.is_valid()) + + def test_valid_mpt_clawback(self): + txn = AMMClawback( + account=_ISSUER_ACCOUNT, + holder=_HOLDER_ACCOUNT, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=MPTAmount( + mpt_issuance_id=_MPT_ISSUANCE_ID_1, + value="10", + ), + ) + self.assertTrue(txn.is_valid()) + + def test_valid_mpt_clawback_without_amount(self): + txn = AMMClawback( + account=_ISSUER_ACCOUNT, + holder=_HOLDER_ACCOUNT, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + ) + self.assertTrue(txn.is_valid()) + + def test_mpt_clawback_amount_type_mismatch(self): + """asset is MPTCurrency but amount is IssuedCurrencyAmount.""" + with self.assertRaises(XRPLModelException) as error: + AMMClawback( + account=_ISSUER_ACCOUNT, + holder=_HOLDER_ACCOUNT, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=IssuedCurrencyAmount( + currency="ETH", + issuer=_ISSUER_ACCOUNT, + value="100", + ), + ) + self.assertEqual( + error.exception.args[0], + "{'AMMClawback': 'Mismatch between Asset and Amount Currency types. " + + "Asset is MPTCurrency whereas Amount is not.'}", + ) + + def test_mpt_clawback_mismatched_issuance_id(self): + """asset and amount are both MPT but have different mpt_issuance_id.""" + with self.assertRaises(XRPLModelException) as error: + AMMClawback( + account=_ISSUER_ACCOUNT, + holder=_HOLDER_ACCOUNT, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=MPTAmount( + mpt_issuance_id=_MPT_ISSUANCE_ID_2, + value="10", + ), + ) + self.assertEqual( + error.exception.args[0], + "{'AMMClawback': 'Mismatch in the Asset.mpt_issuance_id and " + + "Amount.mpt_issuance_id fields'}", + ) + + def test_mpt_clawback_non_hex_characters(self): + bad_id = "Z" * MPT_ISSUANCE_ID_LENGTH + with self.assertRaises(XRPLModelException) as error: + AMMClawback( + account=_ISSUER_ACCOUNT, + holder=_HOLDER_ACCOUNT, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=MPTAmount( + mpt_issuance_id=bad_id, + value="10", + ), + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_clawback_id_too_short(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH - 1) + with self.assertRaises(XRPLModelException) as error: + AMMClawback( + account=_ISSUER_ACCOUNT, + holder=_HOLDER_ACCOUNT, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=MPTAmount( + mpt_issuance_id=bad_id, + value="10", + ), + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_clawback_id_too_long(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH + 1) + with self.assertRaises(XRPLModelException) as error: + AMMClawback( + account=_ISSUER_ACCOUNT, + holder=_HOLDER_ACCOUNT, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=MPTAmount( + mpt_issuance_id=bad_id, + value="10", + ), + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) diff --git a/tests/unit/models/transactions/test_amm_create.py b/tests/unit/models/transactions/test_amm_create.py index f8b3d392a..057084ad5 100644 --- a/tests/unit/models/transactions/test_amm_create.py +++ b/tests/unit/models/transactions/test_amm_create.py @@ -1,12 +1,15 @@ from sys import maxsize from unittest import TestCase -from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.constants import MPT_ISSUANCE_ID_LENGTH +from xrpl.models.amounts import IssuedCurrencyAmount, MPTAmount from xrpl.models.exceptions import XRPLModelException from xrpl.models.transactions import AMMCreate _ACCOUNT = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" _IOU_ISSUER = "rPyfep3gcLzkosKC9XiE77Y8DZWG6iWDT9" +_MPT_ISSUANCE_ID = "00000001A407AF5856CECE4281FED12B7B179B49A4AEF506" +_MPT_ISSUANCE_ID_2 = "00000002A407AF5856CECE4281FED12B7B179B49A4AEF506" class TestAMMCreate(TestCase): @@ -36,6 +39,57 @@ def test_trading_fee_too_high(self): "{'trading_fee': 'Must be between 0 and 1000'}", ) + def test_tx_valid_with_two_mpt_assets(self): + tx = AMMCreate( + account=_ACCOUNT, + amount=MPTAmount( + mpt_issuance_id=_MPT_ISSUANCE_ID, + value="250", + ), + amount2=MPTAmount( + mpt_issuance_id=_MPT_ISSUANCE_ID_2, + value="250", + ), + trading_fee=12, + ) + self.assertTrue(tx.is_valid()) + + def test_mpt_issuance_id_non_hex_characters(self): + bad_id = "Z" * MPT_ISSUANCE_ID_LENGTH + with self.assertRaises(XRPLModelException) as error: + MPTAmount( + mpt_issuance_id=bad_id, + value="250", + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_issuance_id_too_short(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH - 1) + with self.assertRaises(XRPLModelException) as error: + MPTAmount( + mpt_issuance_id=bad_id, + value="250", + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_issuance_id_too_long(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH + 1) + with self.assertRaises(XRPLModelException) as error: + MPTAmount( + mpt_issuance_id=bad_id, + value="250", + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + def test_trading_fee_negative_number(self): with self.assertRaises(XRPLModelException) as error: AMMCreate( diff --git a/tests/unit/models/transactions/test_amm_deposit.py b/tests/unit/models/transactions/test_amm_deposit.py index d39d94aeb..cbf4933d5 100644 --- a/tests/unit/models/transactions/test_amm_deposit.py +++ b/tests/unit/models/transactions/test_amm_deposit.py @@ -1,7 +1,9 @@ from unittest import TestCase -from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.constants import MPT_ISSUANCE_ID_LENGTH +from xrpl.models.amounts import IssuedCurrencyAmount, MPTAmount from xrpl.models.currencies import XRP, IssuedCurrency +from xrpl.models.currencies.mpt_currency import MPTCurrency from xrpl.models.exceptions import XRPLModelException from xrpl.models.transactions import AMMDeposit from xrpl.models.transactions.amm_deposit import AMMDepositFlag @@ -9,6 +11,8 @@ _ACCOUNT = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" _ASSET = XRP() _ASSET2 = IssuedCurrency(currency="ETH", issuer="rpGtkFRXhgVaBzC5XCR7gyE2AZN5SN3SEW") +_MPT_ISSUANCE_ID_1 = "00000001A407AF5856CECE4281FED12B7B179B49A4AEF506" +_MPT_ISSUANCE_ID_2 = "00000002A407AF5856CECE4281FED12B7B179B49A4AEF506" _AMOUNT = "1000" _LPTOKEN_CURRENCY = "B3813FCAB4EE68B3D0D735D6849465A9113EE048" _LPTOKEN_ISSUER = "rH438jEAzTs5PYtV6CHZqpDpwCKQmPW9Cg" @@ -83,6 +87,77 @@ def test_tx_valid_amount_eprice(self): ) self.assertTrue(tx.is_valid()) + def test_tx_valid_single_asset_mpt_deposit(self): + tx = AMMDeposit( + account=_ACCOUNT, + sequence=1337, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=MPTAmount( + mpt_issuance_id=_MPT_ISSUANCE_ID_1, + value="100", + ), + flags=AMMDepositFlag.TF_SINGLE_ASSET, + ) + self.assertTrue(tx.is_valid()) + + def test_mpt_deposit_non_hex_characters(self): + bad_id = "Z" * MPT_ISSUANCE_ID_LENGTH + with self.assertRaises(XRPLModelException) as error: + AMMDeposit( + account=_ACCOUNT, + sequence=1337, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=MPTAmount( + mpt_issuance_id=bad_id, + value="100", + ), + flags=AMMDepositFlag.TF_SINGLE_ASSET, + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_deposit_id_too_short(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH - 1) + with self.assertRaises(XRPLModelException) as error: + AMMDeposit( + account=_ACCOUNT, + sequence=1337, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=MPTAmount( + mpt_issuance_id=bad_id, + value="100", + ), + flags=AMMDepositFlag.TF_SINGLE_ASSET, + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_deposit_id_too_long(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH + 1) + with self.assertRaises(XRPLModelException) as error: + AMMDeposit( + account=_ACCOUNT, + sequence=1337, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=MPTAmount( + mpt_issuance_id=bad_id, + value="100", + ), + flags=AMMDepositFlag.TF_SINGLE_ASSET, + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + def test_undefined_amount_undefined_lptokenout_invalid_combo(self): with self.assertRaises(XRPLModelException) as error: AMMDeposit( diff --git a/tests/unit/models/transactions/test_amm_withdraw.py b/tests/unit/models/transactions/test_amm_withdraw.py index aae365a25..fc2981781 100644 --- a/tests/unit/models/transactions/test_amm_withdraw.py +++ b/tests/unit/models/transactions/test_amm_withdraw.py @@ -1,7 +1,9 @@ from unittest import TestCase -from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.constants import MPT_ISSUANCE_ID_LENGTH +from xrpl.models.amounts import IssuedCurrencyAmount, MPTAmount from xrpl.models.currencies import XRP, IssuedCurrency +from xrpl.models.currencies.mpt_currency import MPTCurrency from xrpl.models.exceptions import XRPLModelException from xrpl.models.transactions import AMMWithdraw from xrpl.models.transactions.amm_withdraw import AMMWithdrawFlag @@ -9,6 +11,8 @@ _ACCOUNT = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" _ASSET = XRP() _ASSET2 = IssuedCurrency(currency="ETH", issuer="rpGtkFRXhgVaBzC5XCR7gyE2AZN5SN3SEW") +_MPT_ISSUANCE_ID_1 = "00000001A407AF5856CECE4281FED12B7B179B49A4AEF506" +_MPT_ISSUANCE_ID_2 = "00000002A407AF5856CECE4281FED12B7B179B49A4AEF506" _AMOUNT = "1000" _LPTOKEN_CURRENCY = "B3813FCAB4EE68B3D0D735D6849465A9113EE048" _LPTOKEN_ISSUER = "rH438jEAzTs5PYtV6CHZqpDpwCKQmPW9Cg" @@ -104,6 +108,77 @@ def test_tx_valid_withdraw_all(self): ) self.assertTrue(tx.is_valid()) + def test_tx_valid_single_asset_mpt_withdraw(self): + tx = AMMWithdraw( + account=_ACCOUNT, + sequence=1337, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=MPTAmount( + mpt_issuance_id=_MPT_ISSUANCE_ID_1, + value="50", + ), + flags=AMMWithdrawFlag.TF_SINGLE_ASSET, + ) + self.assertTrue(tx.is_valid()) + + def test_mpt_withdraw_non_hex_characters(self): + bad_id = "Z" * MPT_ISSUANCE_ID_LENGTH + with self.assertRaises(XRPLModelException) as error: + AMMWithdraw( + account=_ACCOUNT, + sequence=1337, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=MPTAmount( + mpt_issuance_id=bad_id, + value="50", + ), + flags=AMMWithdrawFlag.TF_SINGLE_ASSET, + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_withdraw_id_too_short(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH - 1) + with self.assertRaises(XRPLModelException) as error: + AMMWithdraw( + account=_ACCOUNT, + sequence=1337, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=MPTAmount( + mpt_issuance_id=bad_id, + value="50", + ), + flags=AMMWithdrawFlag.TF_SINGLE_ASSET, + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_withdraw_id_too_long(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH + 1) + with self.assertRaises(XRPLModelException) as error: + AMMWithdraw( + account=_ACCOUNT, + sequence=1337, + asset=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_1), + asset2=MPTCurrency(mpt_issuance_id=_MPT_ISSUANCE_ID_2), + amount=MPTAmount( + mpt_issuance_id=bad_id, + value="50", + ), + flags=AMMWithdrawFlag.TF_SINGLE_ASSET, + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + def test_undefined_amount_defined_amount2_invalid_combo(self): with self.assertRaises(XRPLModelException) as error: AMMWithdraw( diff --git a/tests/unit/models/transactions/test_check_cash.py b/tests/unit/models/transactions/test_check_cash.py index aa3fca77d..91adcff67 100644 --- a/tests/unit/models/transactions/test_check_cash.py +++ b/tests/unit/models/transactions/test_check_cash.py @@ -1,5 +1,7 @@ from unittest import TestCase +from xrpl.constants import MPT_ISSUANCE_ID_LENGTH +from xrpl.models.amounts import MPTAmount from xrpl.models.exceptions import XRPLModelException from xrpl.models.transactions.check_cash import CheckCash @@ -7,6 +9,7 @@ _FEE = "0.00001" _SEQUENCE = 19048 _CHECK_ID = "838766BA2B995C00744175F69A1B11E32C3DBC40E64801A4056FCBD657F57334" +_MPT_ISSUANCE_ID = "00000001A407AF5856CECE4281FED12B7B179B49A4AEF506" _AMOUNT = "300" @@ -50,3 +53,70 @@ def test_deliver_min_without_amount_is_valid(self): deliver_min=_AMOUNT, ) self.assertTrue(tx.is_valid()) + + def test_mpt_amount_is_valid(self): + tx = CheckCash( + account=_ACCOUNT, + fee=_FEE, + sequence=_SEQUENCE, + check_id=_CHECK_ID, + amount=MPTAmount( + mpt_issuance_id=_MPT_ISSUANCE_ID, + value="50", + ), + ) + self.assertTrue(tx.is_valid()) + + def test_mpt_amount_non_hex_characters(self): + bad_id = "Z" * MPT_ISSUANCE_ID_LENGTH + with self.assertRaises(XRPLModelException) as error: + CheckCash( + account=_ACCOUNT, + fee=_FEE, + sequence=_SEQUENCE, + check_id=_CHECK_ID, + amount=MPTAmount( + mpt_issuance_id=bad_id, + value="50", + ), + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_amount_id_too_short(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH - 1) + with self.assertRaises(XRPLModelException) as error: + CheckCash( + account=_ACCOUNT, + fee=_FEE, + sequence=_SEQUENCE, + check_id=_CHECK_ID, + amount=MPTAmount( + mpt_issuance_id=bad_id, + value="50", + ), + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_amount_id_too_long(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH + 1) + with self.assertRaises(XRPLModelException) as error: + CheckCash( + account=_ACCOUNT, + fee=_FEE, + sequence=_SEQUENCE, + check_id=_CHECK_ID, + amount=MPTAmount( + mpt_issuance_id=bad_id, + value="50", + ), + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) diff --git a/tests/unit/models/transactions/test_check_create.py b/tests/unit/models/transactions/test_check_create.py new file mode 100644 index 000000000..f9cc3ea0d --- /dev/null +++ b/tests/unit/models/transactions/test_check_create.py @@ -0,0 +1,95 @@ +from unittest import TestCase + +from xrpl.constants import MPT_ISSUANCE_ID_LENGTH +from xrpl.models.amounts import MPTAmount +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions import CheckCreate + +_ACCOUNT = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" +_DESTINATION = "rNZdsTBP5tH1M6GHC6bTreHAp6ouP8iZSh" +_MPT_ISSUANCE_ID = "00000001A407AF5856CECE4281FED12B7B179B49A4AEF506" + + +class TestCheckCreate(TestCase): + def test_tx_valid_with_xrp(self): + tx = CheckCreate( + account=_ACCOUNT, + destination=_DESTINATION, + send_max="100000000", + ) + self.assertTrue(tx.is_valid()) + + def test_tx_valid_with_mpt(self): + tx = CheckCreate( + account=_ACCOUNT, + destination=_DESTINATION, + send_max=MPTAmount( + mpt_issuance_id=_MPT_ISSUANCE_ID, + value="50", + ), + ) + self.assertTrue(tx.is_valid()) + + def test_tx_valid_with_mpt_and_optional_fields(self): + tx = CheckCreate( + account=_ACCOUNT, + destination=_DESTINATION, + send_max=MPTAmount( + mpt_issuance_id=_MPT_ISSUANCE_ID, + value="50", + ), + destination_tag=1, + expiration=970113521, + invoice_id=( + "6F1DFD1D0FE8A32E40E1F2C05CF1C15545BAB56B617F9C6C2D63A6B704BEF59B" + ), + ) + self.assertTrue(tx.is_valid()) + + def test_mpt_send_max_non_hex_characters(self): + bad_id = "Z" * MPT_ISSUANCE_ID_LENGTH + with self.assertRaises(XRPLModelException) as error: + CheckCreate( + account=_ACCOUNT, + destination=_DESTINATION, + send_max=MPTAmount( + mpt_issuance_id=bad_id, + value="50", + ), + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_send_max_id_too_short(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH - 1) + with self.assertRaises(XRPLModelException) as error: + CheckCreate( + account=_ACCOUNT, + destination=_DESTINATION, + send_max=MPTAmount( + mpt_issuance_id=bad_id, + value="50", + ), + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_send_max_id_too_long(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH + 1) + with self.assertRaises(XRPLModelException) as error: + CheckCreate( + account=_ACCOUNT, + destination=_DESTINATION, + send_max=MPTAmount( + mpt_issuance_id=bad_id, + value="50", + ), + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) diff --git a/tests/unit/models/transactions/test_clawback.py b/tests/unit/models/transactions/test_clawback.py index 8def4d4f6..c452b8cc3 100644 --- a/tests/unit/models/transactions/test_clawback.py +++ b/tests/unit/models/transactions/test_clawback.py @@ -9,9 +9,8 @@ _ISSUED_CURRENCY_AMOUNT = IssuedCurrencyAmount( currency="BTC", value="1.002", issuer=_ACCOUNT ) -_MPT_AMOUNT = MPTAmount( - mpt_issuance_id="000004C463C52827307480341125DA0577DEFC38405B0E3E", value="10" -) +_MPT_ISSUANCE_ID = "000004C463C52827307480341125DA0577DEFC38405B0E3E" +_MPT_AMOUNT = MPTAmount(mpt_issuance_id=_MPT_ISSUANCE_ID, value="10") class TestClawback(TestCase): diff --git a/tests/unit/models/transactions/test_escrow_create.py b/tests/unit/models/transactions/test_escrow_create.py index 45fee8819..569e28b3d 100644 --- a/tests/unit/models/transactions/test_escrow_create.py +++ b/tests/unit/models/transactions/test_escrow_create.py @@ -6,6 +6,7 @@ _SOURCE = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" _DESTINATION = "rJXXwHs6YYZmomBnJoYQdxwXSwJq56tJBn" +_MPT_ISSUANCE_ID = "00000001A407AF5856CECE4281FED12B7B179B49A4AEF506" class TestEscrowCreate(TestCase): @@ -56,7 +57,7 @@ def test_valid_escrow_create(self): account=_SOURCE, destination=_DESTINATION, amount=MPTAmount( - mpt_issuance_id="rHxTJLqdVUxjJuZEZvajXYYQJ7q8p4DhHy", + mpt_issuance_id=_MPT_ISSUANCE_ID, value="10.20", ), cancel_after=10, diff --git a/tests/unit/models/transactions/test_loan_broker_cover_clawback.py b/tests/unit/models/transactions/test_loan_broker_cover_clawback.py index 22ed86361..1a6cf5769 100644 --- a/tests/unit/models/transactions/test_loan_broker_cover_clawback.py +++ b/tests/unit/models/transactions/test_loan_broker_cover_clawback.py @@ -6,6 +6,7 @@ _SOURCE = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" _ISSUER = "rHxTJLqdVUxjJuZEZvajXYYQJ7q8p4DhHy" +_MPT_ISSUANCE_ID = "00000001A407AF5856CECE4281FED12B7B179B49A4AEF506" class TestLoanBrokerCoverClawback(TestCase): @@ -47,7 +48,7 @@ def test_valid_loan_broker_cover_clawback(self): tx = LoanBrokerCoverClawback( account=_SOURCE, amount=MPTAmount( - mpt_issuance_id=_ISSUER, + mpt_issuance_id=_MPT_ISSUANCE_ID, value="10.20", ), loan_broker_id=_ISSUER, diff --git a/tests/unit/models/transactions/test_offer_create.py b/tests/unit/models/transactions/test_offer_create.py index d282931df..de991dd0d 100644 --- a/tests/unit/models/transactions/test_offer_create.py +++ b/tests/unit/models/transactions/test_offer_create.py @@ -1,19 +1,31 @@ from unittest import TestCase +from xrpl.constants import MPT_ISSUANCE_ID_LENGTH +from xrpl.models.amounts import MPTAmount from xrpl.models.exceptions import XRPLModelException from xrpl.models.transactions.offer_create import OfferCreate, OfferCreateFlag _ACCOUNT = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" +_MPT_ISSUANCE_ID_1 = "000004C463C52827307480341125DA0577DEFC38405B0E3E" +_MPT_ISSUANCE_ID_2 = "000004C463C52827307480341125DA0577DEFC38405BABCD" _TAKER_GETS = { "currency": "USD", "issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", "value": "100", } +_TAKER_GETS_MPT = { + "mpt_issuance_id": _MPT_ISSUANCE_ID_1, + "value": "100", +} _TAKER_PAYS = { "currency": "EUR", "issuer": "rJ4EpEPTDR88GpXvix3Y1djATCsDn41ixp", "value": "90", } +_TAKER_PAYS_MPT = { + "mpt_issuance_id": _MPT_ISSUANCE_ID_2, + "value": "30", +} class TestOfferCreate(TestCase): @@ -25,6 +37,30 @@ def test_offer_create_valid(self): ) self.assertTrue(tx.is_valid()) + def test_offer_create_valid_taker_pays_mpt(self): + tx = OfferCreate( + account=_ACCOUNT, + taker_gets=_TAKER_GETS, + taker_pays=_TAKER_PAYS_MPT, + ) + self.assertTrue(tx.is_valid()) + + def test_offer_create_valid_taker_gets_mpt(self): + tx = OfferCreate( + account=_ACCOUNT, + taker_gets=_TAKER_GETS_MPT, + taker_pays=_TAKER_PAYS, + ) + self.assertTrue(tx.is_valid()) + + def test_offer_create_valid_mpt_on_both_sides(self): + tx = OfferCreate( + account=_ACCOUNT, + taker_gets=_TAKER_GETS_MPT, + taker_pays=_TAKER_PAYS_MPT, + ) + self.assertTrue(tx.is_valid()) + def test_offer_create_hybrid_flag_without_domain_id(self): """ A hybrid offer (tfHybrid flag set) should require domain_id. @@ -116,3 +152,51 @@ def test_offer_create_with_domain_id_too_long(self): error.exception.args[0], "{'domain_id': 'domain_id length must be 64 characters.'}", ) + + def test_mpt_taker_gets_non_hex_characters(self): + bad_id = "Z" * MPT_ISSUANCE_ID_LENGTH + with self.assertRaises(XRPLModelException) as error: + OfferCreate( + account=_ACCOUNT, + taker_gets=MPTAmount( + mpt_issuance_id=bad_id, + value="100", + ), + taker_pays=_TAKER_PAYS, + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_taker_gets_id_too_short(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH - 1) + with self.assertRaises(XRPLModelException) as error: + OfferCreate( + account=_ACCOUNT, + taker_gets=MPTAmount( + mpt_issuance_id=bad_id, + value="100", + ), + taker_pays=_TAKER_PAYS, + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) + + def test_mpt_taker_gets_id_too_long(self): + bad_id = "A" * (MPT_ISSUANCE_ID_LENGTH + 1) + with self.assertRaises(XRPLModelException) as error: + OfferCreate( + account=_ACCOUNT, + taker_gets=MPTAmount( + mpt_issuance_id=bad_id, + value="100", + ), + taker_pays=_TAKER_PAYS, + ) + self.assertEqual( + error.exception.args[0], + f"{{'mpt_issuance_id': 'Invalid mpt_issuance_id {bad_id}'}}", + ) diff --git a/xrpl/constants.py b/xrpl/constants.py index 3aa823d16..cdf08e8c7 100644 --- a/xrpl/constants.py +++ b/xrpl/constants.py @@ -42,7 +42,12 @@ class XRPLException(Exception): :meta private: """ -HEX_MPTID_REGEX: Final[Pattern[str]] = re.compile(r"^[0-9A-Fa-f]{48}$") +MPT_ISSUANCE_ID_LENGTH: Final[int] = 48 +"""Length of a valid MPT Issuance ID in hex characters.""" + +HEX_MPTID_REGEX: Final[Pattern[str]] = re.compile( + rf"^[0-9A-Fa-f]{{{MPT_ISSUANCE_ID_LENGTH}}}$" +) # Constants for validating amounts. MIN_IOU_EXPONENT: Final[int] = -96 diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index 8868ed103..1b0184fc8 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -3309,6 +3309,26 @@ "type": "Hash192" } ], + [ + "TakerPaysMPT", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 3, + "type": "Hash192" + } + ], + [ + "TakerGetsMPT", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 4, + "type": "Hash192" + } + ], [ "LockingChainIssue", { diff --git a/xrpl/core/binarycodec/types/path_set.py b/xrpl/core/binarycodec/types/path_set.py index 0964ff6b7..f77ffe18e 100644 --- a/xrpl/core/binarycodec/types/path_set.py +++ b/xrpl/core/binarycodec/types/path_set.py @@ -12,12 +12,14 @@ from xrpl.core.binarycodec.exceptions import XRPLBinaryCodecException from xrpl.core.binarycodec.types.account_id import AccountID 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 # Constant for masking types of a PathStep _TYPE_ACCOUNT: Final[int] = 0x01 _TYPE_CURRENCY: Final[int] = 0x10 _TYPE_ISSUER: Final[int] = 0x20 +_TYPE_MPT: Final[int] = 0x40 # Constants for separating Paths in a PathSet _PATHSET_END_BYTE: Final[int] = 0x00 @@ -26,7 +28,12 @@ def _is_path_step(value: Dict[str, str]) -> bool: """Helper function to determine if a dictionary represents a valid path step.""" - return "issuer" in value or "account" in value or "currency" in value + return ( + "issuer" in value + or "account" in value + or "currency" in value + or "mpt_issuance_id" in value + ) def _is_path_set(value: List[List[Dict[str, str]]]) -> bool: @@ -57,6 +64,11 @@ def from_value(cls: Type[Self], value: Dict[str, str]) -> Self: f" received {value.__class__.__name__}." ) + if "currency" in value and "mpt_issuance_id" in value: + raise XRPLBinaryCodecException( + "Currency and mpt_issuance_id are mutually exclusive in a path step" + ) + data_type = 0x00 buffer = b"" if "account" in value: @@ -67,6 +79,10 @@ def from_value(cls: Type[Self], value: Dict[str, str]) -> Self: currency = Currency.from_value(value["currency"]) buffer += bytes(currency) data_type |= _TYPE_CURRENCY + elif "mpt_issuance_id" in value: + mpt_id = Hash192.from_value(value["mpt_issuance_id"]) + buffer += bytes(mpt_id) + data_type |= _TYPE_MPT if "issuer" in value: issuer = AccountID.from_value(value["issuer"]) buffer += bytes(issuer) @@ -90,12 +106,21 @@ def from_parser( data_type = parser.read_uint8() buffer = b"" + if (data_type & _TYPE_CURRENCY) and (data_type & _TYPE_MPT): + raise XRPLBinaryCodecException( + "Invalid binary input: Currency and mpt_issuance_id are " + "mutually exclusive in a path step" + ) + if data_type & _TYPE_ACCOUNT: account_id = parser.read(AccountID.LENGTH) buffer += account_id if data_type & _TYPE_CURRENCY: currency = parser.read(Currency.LENGTH) buffer += currency + elif data_type & _TYPE_MPT: + mpt_id = parser.read(HASH192_BYTES) + buffer += mpt_id if data_type & _TYPE_ISSUER: issuer = parser.read(AccountID.LENGTH) buffer += issuer @@ -119,6 +144,9 @@ def to_json(self: Self) -> Dict[str, str]: if data_type & _TYPE_CURRENCY: currency = Currency.from_parser(parser).to_json() json["currency"] = currency + elif data_type & _TYPE_MPT: + mpt_id = parser.read(HASH192_BYTES).hex().upper() + json["mpt_issuance_id"] = mpt_id if data_type & _TYPE_ISSUER: issuer = AccountID.from_parser(parser).to_json() json["issuer"] = issuer diff --git a/xrpl/models/amounts/mpt_amount.py b/xrpl/models/amounts/mpt_amount.py index a89cd9d42..4aa817c91 100644 --- a/xrpl/models/amounts/mpt_amount.py +++ b/xrpl/models/amounts/mpt_amount.py @@ -7,6 +7,7 @@ from typing_extensions import Self +from xrpl.constants import HEX_MPTID_REGEX from xrpl.models.base_model import BaseModel from xrpl.models.currencies.mpt_currency import MPTCurrency from xrpl.models.required import REQUIRED @@ -32,6 +33,16 @@ class MPTAmount(BaseModel): :meta hide-value: """ + def _get_errors(self: Self) -> Dict[str, str]: + errors = super()._get_errors() + if self.mpt_issuance_id is not REQUIRED and not HEX_MPTID_REGEX.fullmatch( + self.mpt_issuance_id + ): + errors["mpt_issuance_id"] = ( + f"Invalid mpt_issuance_id {self.mpt_issuance_id}" + ) + return errors + def to_dict(self: Self) -> Dict[str, str]: """ Returns the dictionary representation of an MPTAmount. diff --git a/xrpl/models/path.py b/xrpl/models/path.py index e1671c85c..bad105718 100644 --- a/xrpl/models/path.py +++ b/xrpl/models/path.py @@ -22,6 +22,7 @@ class PathStep(BaseModel): account: Optional[str] = None currency: Optional[str] = None issuer: Optional[str] = None + mpt_issuance_id: Optional[str] = None type: Optional[int] = None type_hex: Optional[str] = None @@ -33,6 +34,7 @@ def _get_errors(self: Self) -> Dict[str, str]: "account": self._get_account_error(), "currency": self._get_currency_error(), "issuer": self._get_issuer_error(), + "mpt_issuance_id": self._get_mpt_issuance_id_error(), }.items() if value is not None } @@ -42,6 +44,8 @@ def _get_account_error(self: Self) -> Optional[str]: return None if self.currency is not None or self.issuer is not None: return "Cannot set account if currency or issuer are set" + if self.mpt_issuance_id is not None: + return "Cannot set account if mpt_issuance_id is specified" return None def _get_currency_error(self: Self) -> Optional[str]: @@ -49,6 +53,8 @@ def _get_currency_error(self: Self) -> Optional[str]: return None if self.account is not None: return "Cannot set currency if account is set" + if self.mpt_issuance_id is not None: + return "Cannot set both currency and mpt_issuance_id" if self.issuer is not None and self.currency.upper() == "XRP": return "Cannot set issuer if currency is XRP" return None @@ -62,6 +68,15 @@ def _get_issuer_error(self: Self) -> Optional[str]: return "Cannot set issuer if currency is XRP" return None + def _get_mpt_issuance_id_error(self: Self) -> Optional[str]: + if self.mpt_issuance_id is None: + return None + if self.currency is not None: + return "Cannot set both mpt_issuance_id and currency" + if self.account is not None: + return "Cannot set both mpt_issuance_id and account" + return None + Path = List[PathStep] """ diff --git a/xrpl/models/requests/ledger_entry.py b/xrpl/models/requests/ledger_entry.py index 01afb02ae..5b5724ddf 100644 --- a/xrpl/models/requests/ledger_entry.py +++ b/xrpl/models/requests/ledger_entry.py @@ -15,6 +15,7 @@ from typing_extensions import Self from xrpl.models.base_model import BaseModel +from xrpl.models.currencies import Currency from xrpl.models.requests.request import LookupByLedgerRequest, Request, RequestMethod from xrpl.models.required import REQUIRED from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init @@ -335,6 +336,29 @@ class XChainCreateAccountClaimID(XChainBridge): """ +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class AMM(BaseModel): + """ + Required fields for requesting an AMM ledger entry if not querying by + object ID. + """ + + asset: Currency = REQUIRED + """ + One of the assets in the AMM's pool. This field is required. + + :meta hide-value: + """ + + asset2: Currency = REQUIRED + """ + The other asset in the AMM's pool. This field is required. + + :meta hide-value: + """ + + @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) class LedgerEntry(Request, LookupByLedgerRequest): @@ -349,6 +373,7 @@ class LedgerEntry(Request, LookupByLedgerRequest): method: RequestMethod = field(default=RequestMethod.LEDGER_ENTRY, init=False) index: Optional[str] = None account_root: Optional[str] = None + amm: Optional[Union[str, AMM]] = None check: Optional[str] = None credential: Optional[Union[str, Credential]] = None delegate: Optional[Union[str, Delegate]] = None @@ -385,6 +410,7 @@ def _get_errors(self: Self) -> Dict[str, str]: for param in [ self.index, self.account_root, + self.amm, self.check, self.credential, self.delegate, diff --git a/xrpl/models/transactions/amm_clawback.py b/xrpl/models/transactions/amm_clawback.py index 5b0d8c6ec..78b4467df 100644 --- a/xrpl/models/transactions/amm_clawback.py +++ b/xrpl/models/transactions/amm_clawback.py @@ -4,13 +4,12 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Dict, Optional +from typing import Dict, Optional, Union from typing_extensions import Self -from xrpl.models.amounts import IssuedCurrencyAmount -from xrpl.models.currencies import Currency -from xrpl.models.currencies.issued_currency import IssuedCurrency +from xrpl.models.amounts import Amount, IssuedCurrencyAmount, MPTAmount +from xrpl.models.currencies import Currency, IssuedCurrency, MPTCurrency from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import Transaction, TransactionFlagInterface from xrpl.models.transactions.types import TransactionType @@ -50,7 +49,7 @@ class AMMClawback(Transaction): holder: str = REQUIRED """The account holding the asset to be clawed back.""" - asset: IssuedCurrency = REQUIRED + asset: Union[IssuedCurrency, MPTCurrency] = REQUIRED """ Specifies the asset that the issuer wants to claw back from the AMM pool. In JSON, this is an object with currency and issuer fields. The issuer field must match with @@ -63,7 +62,7 @@ class AMMClawback(Transaction): currency and issuer fields (omit issuer for XRP). """ - amount: Optional[IssuedCurrencyAmount] = None + amount: Optional[Amount] = None """ The maximum amount to claw back from the AMM account. The currency and issuer subfields should match the Asset subfields. If this field isn't specified, or the @@ -91,18 +90,40 @@ def _validate_wallet_and_amount_fields(self: Self) -> Optional[str]: if self.account == self.holder: errors += "Issuer and holder wallets must be distinct." - if self.account != self.asset.issuer: - errors += ( - "Asset.issuer and AMMClawback transaction sender must be identical." - ) - - if self.amount is not None and ( - self.amount.issuer != self.asset.issuer - or self.amount.currency != self.asset.currency - ): - errors += ( - "Amount.issuer and Amount.currency must match corresponding Asset " - + "fields." - ) + # If the asset is an MPT, the library will only have MPTIssuanceID. + # Further transaction validation will require a network call to read the + # blockchain state, which will introduce non-deterministic latency. + # Hence skipping any such validation. + if isinstance(self.asset, IssuedCurrency): + if self.account != self.asset.issuer: + errors += ( + "Asset.issuer and AMMClawback transaction sender must be identical." + ) + + if ( + self.amount is not None + and isinstance(self.amount, IssuedCurrencyAmount) + and ( + self.amount.issuer != self.asset.issuer + or self.amount.currency != self.asset.currency + ) + ): + errors += ( + "Amount.issuer and Amount.currency must match corresponding Asset " + + "fields." + ) + + if isinstance(self.asset, MPTCurrency): + if self.amount is not None: + if not isinstance(self.amount, MPTAmount): + errors += ( + "Mismatch between Asset and Amount Currency types. Asset " + + "is MPTCurrency whereas Amount is not." + ) + elif self.amount.mpt_issuance_id != self.asset.mpt_issuance_id: + errors += ( + "Mismatch in the Asset.mpt_issuance_id and " + + "Amount.mpt_issuance_id fields" + ) return errors if errors else None