From 2b0d5de01fa943448c08aa5b36d056fe7ed54819 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Mon, 20 Oct 2025 12:46:08 -0400 Subject: [PATCH 1/5] Support for Dynamic MPT (XLS-94d) --- .ci-config/rippled.cfg | 1 + CHANGELOG.md | 4 + .../test_mptoken_issuance_dynamic_mpt.py | 485 ++++++++++++++++++ .../test_mptoken_issuance_create.py | 36 +- .../transactions/test_mptoken_issuance_set.py | 173 ++++++- .../binarycodec/definitions/definitions.json | 28 +- xrpl/models/transactions/__init__.py | 4 + .../transactions/mptoken_issuance_create.py | 65 +++ .../transactions/mptoken_issuance_set.py | 222 +++++++- 9 files changed, 1010 insertions(+), 8 deletions(-) create mode 100644 tests/integration/transactions/test_mptoken_issuance_dynamic_mpt.py diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index b6b907c8d..4e48e992a 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -206,6 +206,7 @@ PermissionDelegation PermissionedDEX Batch TokenEscrow +DynamicMPT # This section can be used to simulate various FeeSettings scenarios for rippled node in standalone mode [voting] diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e1d178d..dc82bc275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [[Unreleased]] +### Added + +- Support for `Dynamic Multi-Purpose Tokens` (XLS-94d) + ### Fixed - Removed snippets files from the xrpl-py code repository. Updated the README file to point to the correct location on XRPL.org. diff --git a/tests/integration/transactions/test_mptoken_issuance_dynamic_mpt.py b/tests/integration/transactions/test_mptoken_issuance_dynamic_mpt.py new file mode 100644 index 000000000..9751b35de --- /dev/null +++ b/tests/integration/transactions/test_mptoken_issuance_dynamic_mpt.py @@ -0,0 +1,485 @@ +"""Integration tests for DynamicMPT (XLS-94) feature.""" + +import json + +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + sign_and_reliable_submission_async, + test_async_and_sync, +) +from tests.integration.reusable_values import WALLET +from xrpl.models.requests.account_objects import AccountObjects, AccountObjectType +from xrpl.models.requests.tx import Tx +from xrpl.models.transactions import ( + MPTokenIssuanceCreate, + MPTokenIssuanceCreateFlag, + MPTokenIssuanceCreateMutableFlag, + MPTokenIssuanceSet, + MPTokenIssuanceSetMutableFlag, +) +from xrpl.utils import str_to_hex + +# Ledger flag constants for MPTokenIssuance +LSF_MPT_CAN_LOCK = 0x00000002 +LSF_MPT_REQUIRE_AUTH = 0x00000004 +LSF_MPT_CAN_ESCROW = 0x00000008 +LSF_MPT_CAN_TRADE = 0x00000010 +LSF_MPT_CAN_TRANSFER = 0x00000020 +LSF_MPT_CAN_CLAWBACK = 0x00000040 + + +class TestDynamicMPT(IntegrationTestCase): + """Test DynamicMPT functionality including mutable fields and flags.""" + + @test_async_and_sync(globals()) + async def test_create_with_mutable_metadata(self, client): + """Test creating MPT with mutable metadata flag.""" + metadata = { + "ticker": "DMPT", + "name": "Dynamic MPT", + "icon": "https://example.org/dmpt.png", + } + + tx = MPTokenIssuanceCreate( + account=WALLET.classic_address, + asset_scale=2, + mptoken_metadata=str_to_hex(json.dumps(metadata)), + mutable_flags=MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_METADATA, + ) + + response = await sign_and_reliable_submission_async(tx, WALLET, client) + + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Get the MPTokenIssuanceID using Tx request + tx_hash = response.result["tx_json"]["hash"] + tx_response = await client.request(Tx(transaction=tx_hash)) + mpt_id = tx_response.result["meta"]["mpt_issuance_id"] + + # Verify MPTokenIssuance was created with correct metadata + account_objects_response = await client.request( + AccountObjects(account=WALLET.address, type=AccountObjectType.MPT_ISSUANCE) + ) + self.assertTrue(len(account_objects_response.result["account_objects"]) > 0) + + # Find the created MPTokenIssuance object + mpt_issuance = next( + obj + for obj in account_objects_response.result["account_objects"] + if obj["mpt_issuance_id"] == mpt_id + ) + + # Verify metadata was set correctly + # (compare uppercase to handle hex case differences) + self.assertEqual( + mpt_issuance["MPTokenMetadata"].upper(), + str_to_hex(json.dumps(metadata)).upper(), + ) + + @test_async_and_sync(globals()) + async def test_create_with_mutable_transfer_fee(self, client): + """Test creating MPT with mutable transfer fee flag.""" + tx = MPTokenIssuanceCreate( + account=WALLET.classic_address, + asset_scale=2, + flags=MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + transfer_fee=100, + mutable_flags=( + MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_TRANSFER_FEE + ), + ) + + response = await sign_and_reliable_submission_async(tx, WALLET, client) + + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Get the MPTokenIssuanceID using Tx request + tx_hash = response.result["tx_json"]["hash"] + tx_response = await client.request(Tx(transaction=tx_hash)) + mpt_id = tx_response.result["meta"]["mpt_issuance_id"] + + # Verify transfer fee was set correctly + account_objects_response = await client.request( + AccountObjects(account=WALLET.address, type=AccountObjectType.MPT_ISSUANCE) + ) + + mpt_issuance = next( + obj + for obj in account_objects_response.result["account_objects"] + if obj["mpt_issuance_id"] == mpt_id + ) + + # Verify TransferFee field was set + self.assertEqual(mpt_issuance["TransferFee"], 100) + + @test_async_and_sync(globals()) + async def test_create_with_mutable_flags(self, client): + """Test creating MPT with mutable flags for CAN_LOCK and CAN_ESCROW.""" + tx = MPTokenIssuanceCreate( + account=WALLET.classic_address, + asset_scale=2, + mutable_flags=( + MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_CAN_LOCK + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_CAN_ESCROW + ), + ) + + response = await sign_and_reliable_submission_async(tx, WALLET, client) + + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + @test_async_and_sync(globals()) + async def test_update_metadata(self, client): + """Test updating metadata on an MPT with mutable metadata.""" + # Create MPT with mutable metadata + metadata1 = { + "ticker": "DMPT", + "name": "Dynamic MPT v1", + "icon": "https://example.org/v1.png", + } + + create_tx = MPTokenIssuanceCreate( + account=WALLET.classic_address, + asset_scale=2, + mptoken_metadata=str_to_hex(json.dumps(metadata1)), + mutable_flags=MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_METADATA, + ) + + create_response = await sign_and_reliable_submission_async( + create_tx, WALLET, client + ) + self.assertTrue(create_response.is_successful()) + + # Get the MPTokenIssuanceID using Tx request + tx_hash = create_response.result["tx_json"]["hash"] + tx_response = await client.request(Tx(transaction=tx_hash)) + mpt_id = tx_response.result["meta"]["mpt_issuance_id"] + + # Update metadata + metadata2 = { + "ticker": "DMPT", + "name": "Dynamic MPT v2", + "icon": "https://example.org/v2.png", + } + + update_tx = MPTokenIssuanceSet( + account=WALLET.classic_address, + mptoken_issuance_id=mpt_id, + mptoken_metadata=str_to_hex(json.dumps(metadata2)), + ) + + update_response = await sign_and_reliable_submission_async( + update_tx, WALLET, client + ) + self.assertTrue(update_response.is_successful()) + self.assertEqual(update_response.result["engine_result"], "tesSUCCESS") + + # Verify metadata was actually updated on the ledger + account_objects_response = await client.request( + AccountObjects(account=WALLET.address, type=AccountObjectType.MPT_ISSUANCE) + ) + + mpt_issuance = next( + obj + for obj in account_objects_response.result["account_objects"] + if obj["mpt_issuance_id"] == mpt_id + ) + + # Verify the metadata field was updated to metadata2 + self.assertEqual( + mpt_issuance["MPTokenMetadata"].upper(), + str_to_hex(json.dumps(metadata2)).upper(), + ) + + @test_async_and_sync(globals()) + async def test_update_transfer_fee(self, client): + """Test updating transfer fee on an MPT.""" + # Create MPT with mutable transfer fee + create_tx = MPTokenIssuanceCreate( + account=WALLET.classic_address, + asset_scale=2, + flags=MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + transfer_fee=100, + mutable_flags=( + MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_TRANSFER_FEE + ), + ) + + create_response = await sign_and_reliable_submission_async( + create_tx, WALLET, client + ) + self.assertTrue(create_response.is_successful()) + + # Get the MPTokenIssuanceID using Tx request + tx_hash = create_response.result["tx_json"]["hash"] + tx_response = await client.request(Tx(transaction=tx_hash)) + mpt_id = tx_response.result["meta"]["mpt_issuance_id"] + + # Update transfer fee + update_tx = MPTokenIssuanceSet( + account=WALLET.classic_address, + mptoken_issuance_id=mpt_id, + transfer_fee=200, + ) + + update_response = await sign_and_reliable_submission_async( + update_tx, WALLET, client + ) + self.assertTrue(update_response.is_successful()) + self.assertEqual(update_response.result["engine_result"], "tesSUCCESS") + + # Verify transfer fee was actually updated on the ledger + account_objects_response = await client.request( + AccountObjects(account=WALLET.address, type=AccountObjectType.MPT_ISSUANCE) + ) + + mpt_issuance = next( + obj + for obj in account_objects_response.result["account_objects"] + if obj["mpt_issuance_id"] == mpt_id + ) + + # Verify the TransferFee field was updated to 200 + self.assertEqual(mpt_issuance["TransferFee"], 200) + + @test_async_and_sync(globals()) + async def test_set_and_clear_flags(self, client): + """Test setting and clearing mutable flags.""" + # Create MPT with mutable CAN_LOCK flag + create_tx = MPTokenIssuanceCreate( + account=WALLET.classic_address, + asset_scale=2, + mutable_flags=MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_CAN_LOCK, + ) + + create_response = await sign_and_reliable_submission_async( + create_tx, WALLET, client + ) + self.assertTrue(create_response.is_successful()) + + # Get the MPTokenIssuanceID using Tx request + tx_hash = create_response.result["tx_json"]["hash"] + tx_response = await client.request(Tx(transaction=tx_hash)) + mpt_id = tx_response.result["meta"]["mpt_issuance_id"] + + # Set CAN_LOCK flag + set_tx = MPTokenIssuanceSet( + account=WALLET.classic_address, + mptoken_issuance_id=mpt_id, + mutable_flags=MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_LOCK, + ) + + set_response = await sign_and_reliable_submission_async(set_tx, WALLET, client) + self.assertTrue(set_response.is_successful()) + self.assertEqual(set_response.result["engine_result"], "tesSUCCESS") + + # Verify CAN_LOCK flag was set on the ledger + account_objects_response = await client.request( + AccountObjects(account=WALLET.address, type=AccountObjectType.MPT_ISSUANCE) + ) + + mpt_issuance = next( + obj + for obj in account_objects_response.result["account_objects"] + if obj["mpt_issuance_id"] == mpt_id + ) + + # Verify lsfMPTCanLock flag is set + self.assertTrue(mpt_issuance["Flags"] & LSF_MPT_CAN_LOCK) + + # Clear CAN_LOCK flag + clear_tx = MPTokenIssuanceSet( + account=WALLET.classic_address, + mptoken_issuance_id=mpt_id, + mutable_flags=MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_CAN_LOCK, + ) + + clear_response = await sign_and_reliable_submission_async( + clear_tx, WALLET, client + ) + self.assertTrue(clear_response.is_successful()) + self.assertEqual(clear_response.result["engine_result"], "tesSUCCESS") + + # Verify CAN_LOCK flag was cleared on the ledger + account_objects_response = await client.request( + AccountObjects(account=WALLET.address, type=AccountObjectType.MPT_ISSUANCE) + ) + + mpt_issuance = next( + obj + for obj in account_objects_response.result["account_objects"] + if obj["mpt_issuance_id"] == mpt_id + ) + + # Verify lsfMPTCanLock flag is cleared + self.assertFalse(mpt_issuance["Flags"] & LSF_MPT_CAN_LOCK) + + @test_async_and_sync(globals()) + async def test_multiple_mutable_flags(self, client): + """Test setting multiple mutable flags simultaneously.""" + # Create MPT with multiple mutable flags + create_tx = MPTokenIssuanceCreate( + account=WALLET.classic_address, + asset_scale=2, + flags=MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + mutable_flags=( + MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_CAN_LOCK + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_CAN_ESCROW + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_TRANSFER_FEE + ), + ) + + create_response = await sign_and_reliable_submission_async( + create_tx, WALLET, client + ) + self.assertTrue(create_response.is_successful()) + + # Get the MPTokenIssuanceID using Tx request + tx_hash = create_response.result["tx_json"]["hash"] + tx_response = await client.request(Tx(transaction=tx_hash)) + mpt_id = tx_response.result["meta"]["mpt_issuance_id"] + + # Set multiple flags and transfer fee + update_tx = MPTokenIssuanceSet( + account=WALLET.classic_address, + mptoken_issuance_id=mpt_id, + mutable_flags=( + MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_LOCK + | MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_CAN_ESCROW + ), + transfer_fee=150, + ) + + update_response = await sign_and_reliable_submission_async( + update_tx, WALLET, client + ) + self.assertTrue(update_response.is_successful()) + self.assertEqual(update_response.result["engine_result"], "tesSUCCESS") + + # Verify flags and transfer fee were updated on the ledger + account_objects_response = await client.request( + AccountObjects(account=WALLET.address, type=AccountObjectType.MPT_ISSUANCE) + ) + + mpt_issuance = next( + obj + for obj in account_objects_response.result["account_objects"] + if obj["mpt_issuance_id"] == mpt_id + ) + + # Verify CAN_LOCK flag is set + self.assertTrue(mpt_issuance["Flags"] & LSF_MPT_CAN_LOCK) + + # Verify CAN_ESCROW flag is cleared + self.assertFalse(mpt_issuance["Flags"] & LSF_MPT_CAN_ESCROW) + + # Verify TransferFee was set to 150 + self.assertEqual(mpt_issuance["TransferFee"], 150) + + @test_async_and_sync(globals()) + async def test_remove_metadata(self, client): + """Test removing metadata by setting empty string.""" + # Create MPT with metadata + metadata = { + "ticker": "DMPT", + "name": "Dynamic MPT", + "icon": "https://example.org/dmpt.png", + } + + create_tx = MPTokenIssuanceCreate( + account=WALLET.classic_address, + asset_scale=2, + mptoken_metadata=str_to_hex(json.dumps(metadata)), + mutable_flags=MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_METADATA, + ) + + create_response = await sign_and_reliable_submission_async( + create_tx, WALLET, client + ) + self.assertTrue(create_response.is_successful()) + + # Get the MPTokenIssuanceID using Tx request + tx_hash = create_response.result["tx_json"]["hash"] + tx_response = await client.request(Tx(transaction=tx_hash)) + mpt_id = tx_response.result["meta"]["mpt_issuance_id"] + + # Remove metadata with empty string + update_tx = MPTokenIssuanceSet( + account=WALLET.classic_address, + mptoken_issuance_id=mpt_id, + mptoken_metadata="", + ) + + update_response = await sign_and_reliable_submission_async( + update_tx, WALLET, client + ) + self.assertTrue(update_response.is_successful()) + self.assertEqual(update_response.result["engine_result"], "tesSUCCESS") + + # Verify metadata was removed from the ledger + account_objects_response = await client.request( + AccountObjects(account=WALLET.address, type=AccountObjectType.MPT_ISSUANCE) + ) + + mpt_issuance = next( + obj + for obj in account_objects_response.result["account_objects"] + if obj["mpt_issuance_id"] == mpt_id + ) + + # Verify MPTokenMetadata field is absent + self.assertNotIn("MPTokenMetadata", mpt_issuance) + + @test_async_and_sync(globals()) + async def test_remove_transfer_fee(self, client): + """Test removing transfer fee by setting it to zero.""" + # Create MPT with transfer fee + create_tx = MPTokenIssuanceCreate( + account=WALLET.classic_address, + asset_scale=2, + flags=MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + transfer_fee=100, + mutable_flags=( + MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_TRANSFER_FEE + ), + ) + + create_response = await sign_and_reliable_submission_async( + create_tx, WALLET, client + ) + self.assertTrue(create_response.is_successful()) + + # Get the MPTokenIssuanceID using Tx request + tx_hash = create_response.result["tx_json"]["hash"] + tx_response = await client.request(Tx(transaction=tx_hash)) + mpt_id = tx_response.result["meta"]["mpt_issuance_id"] + + # Remove transfer fee by setting to zero + update_tx = MPTokenIssuanceSet( + account=WALLET.classic_address, + mptoken_issuance_id=mpt_id, + transfer_fee=0, + ) + + update_response = await sign_and_reliable_submission_async( + update_tx, WALLET, client + ) + self.assertTrue(update_response.is_successful()) + self.assertEqual(update_response.result["engine_result"], "tesSUCCESS") + + # Verify transfer fee was removed from the ledger + account_objects_response = await client.request( + AccountObjects(account=WALLET.address, type=AccountObjectType.MPT_ISSUANCE) + ) + + mpt_issuance = next( + obj + for obj in account_objects_response.result["account_objects"] + if obj["mpt_issuance_id"] == mpt_id + ) + + # Verify TransferFee field is absent + self.assertNotIn("TransferFee", mpt_issuance) diff --git a/tests/unit/models/transactions/test_mptoken_issuance_create.py b/tests/unit/models/transactions/test_mptoken_issuance_create.py index f06bcdc03..0a12d9587 100644 --- a/tests/unit/models/transactions/test_mptoken_issuance_create.py +++ b/tests/unit/models/transactions/test_mptoken_issuance_create.py @@ -3,7 +3,11 @@ from unittest import TestCase from xrpl.models.exceptions import XRPLModelException -from xrpl.models.transactions import MPTokenIssuanceCreate, MPTokenIssuanceCreateFlag +from xrpl.models.transactions import ( + MPTokenIssuanceCreate, + MPTokenIssuanceCreateFlag, + MPTokenIssuanceCreateMutableFlag, +) from xrpl.utils import str_to_hex _ACCOUNT = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" @@ -123,3 +127,33 @@ def test_tx_emits_warning_for_missing_icon_metadata(self): for msg in warning_messages ) self.assertTrue(found, "- icon is required and must be string.") + + # DynamicMPT tests + def test_tx_with_mutable_flags(self): + tx = MPTokenIssuanceCreate( + account=_ACCOUNT, + flags=MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + transfer_fee=100, + mutable_flags=MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_METADATA + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_TRANSFER_FEE, + ) + self.assertTrue(tx.is_valid()) + + def test_tx_mutable_flags_zero_fails(self): + with self.assertRaises(XRPLModelException) as error: + MPTokenIssuanceCreate( + account=_ACCOUNT, + mutable_flags=0, + ) + self.assertIn("mutable_flags cannot be 0", error.exception.args[0]) + + def test_tx_mutable_flags_invalid_bits_fails(self): + # Test reserved bit (0x00000001) + with self.assertRaises(XRPLModelException) as error: + MPTokenIssuanceCreate( + account=_ACCOUNT, + mutable_flags=0x00000001, + ) + self.assertIn( + "mutable_flags contains invalid or reserved bits", error.exception.args[0] + ) diff --git a/tests/unit/models/transactions/test_mptoken_issuance_set.py b/tests/unit/models/transactions/test_mptoken_issuance_set.py index 5e163cb05..123f2c0d9 100644 --- a/tests/unit/models/transactions/test_mptoken_issuance_set.py +++ b/tests/unit/models/transactions/test_mptoken_issuance_set.py @@ -1,8 +1,13 @@ +import json from unittest import TestCase from xrpl.models.exceptions import XRPLModelException from xrpl.models.transactions import MPTokenIssuanceSet -from xrpl.models.transactions.mptoken_issuance_set import MPTokenIssuanceSetFlag +from xrpl.models.transactions.mptoken_issuance_set import ( + MPTokenIssuanceSetFlag, + MPTokenIssuanceSetMutableFlag, +) +from xrpl.utils import str_to_hex _ACCOUNT = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" _TOKEN_ID = "000004C463C52827307480341125DA0577DEFC38405B0E3E" @@ -49,3 +54,169 @@ def test_tx_with_flag_conflict(self): "{'flags': \"flag conflict: both TF_MPT_LOCK and TF_MPT_UNLOCK can't be set" '"}', ) + + # DynamicMPT tests + def test_tx_with_mptoken_metadata(self): + metadata = {"ticker": "TBILL", "name": "T-Bill", "icon": "https://ex.org/i.png"} + tx = MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + mptoken_metadata=str_to_hex(json.dumps(metadata)), + ) + self.assertTrue(tx.is_valid()) + + def test_tx_with_empty_mptoken_metadata(self): + # Empty string removes the metadata field + tx = MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + mptoken_metadata="", + ) + self.assertTrue(tx.is_valid()) + + def test_tx_with_transfer_fee(self): + tx = MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + transfer_fee=200, + ) + self.assertTrue(tx.is_valid()) + + def test_tx_with_zero_transfer_fee(self): + # Zero removes the transfer_fee field + tx = MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + transfer_fee=0, + ) + self.assertTrue(tx.is_valid()) + + def test_tx_with_mutable_flags_set_can_lock(self): + tx = MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + mutable_flags=MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_LOCK, + ) + self.assertTrue(tx.is_valid()) + + def test_tx_with_mutable_flags_clear_can_lock(self): + tx = MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + mutable_flags=MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_CAN_LOCK, + ) + self.assertTrue(tx.is_valid()) + + def test_tx_with_multiple_mutable_flags(self): + tx = MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + mutable_flags=MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_LOCK + | MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_CAN_ESCROW, + ) + self.assertTrue(tx.is_valid()) + + def test_tx_with_mutable_flags_and_transfer_fee(self): + tx = MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + mutable_flags=MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_LOCK, + transfer_fee=200, + ) + self.assertTrue(tx.is_valid()) + + def test_tx_holder_with_dynamic_fields_fails(self): + with self.assertRaises(XRPLModelException) as error: + MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + holder="rajgkBmMxmz161r8bWYH7CQAFZP5bA9oSG", + mptoken_metadata="464F4F", + ) + self.assertIn("holder cannot be provided", error.exception.args[0]) + + def test_tx_flags_with_dynamic_fields_fails(self): + with self.assertRaises(XRPLModelException) as error: + MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + flags=MPTokenIssuanceSetFlag.TF_MPT_LOCK, + mptoken_metadata="464F4F", + ) + self.assertIn("Flags cannot be provided when", error.exception.args[0]) + + def test_tx_mutable_flags_zero_fails(self): + with self.assertRaises(XRPLModelException) as error: + MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + mutable_flags=0, + ) + self.assertIn("mutable_flags cannot be 0", error.exception.args[0]) + + def test_tx_mutable_flags_conflict_can_lock(self): + with self.assertRaises(XRPLModelException) as error: + MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + mutable_flags=MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_LOCK + | MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_CAN_LOCK, + ) + self.assertIn("Cannot set and clear CAN_LOCK", error.exception.args[0]) + + def test_tx_mutable_flags_conflict_require_auth(self): + with self.assertRaises(XRPLModelException) as error: + MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + mutable_flags=MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_REQUIRE_AUTH + | MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_REQUIRE_AUTH, + ) + self.assertIn("Cannot set and clear REQUIRE_AUTH", error.exception.args[0]) + + def test_tx_transfer_fee_with_clear_can_transfer_fails(self): + with self.assertRaises(XRPLModelException) as error: + MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + transfer_fee=200, + mutable_flags=MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_CAN_TRANSFER, + ) + self.assertIn( + "Cannot include non-zero transfer_fee when clearing CAN_TRANSFER", + error.exception.args[0], + ) + + def test_tx_transfer_fee_out_of_range(self): + with self.assertRaises(XRPLModelException) as error: + MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + transfer_fee=50001, + ) + self.assertIn( + "transfer_fee must be between 0 and 50000", error.exception.args[0] + ) + + def test_tx_mptoken_metadata_too_long(self): + # Create a hex string longer than 2048 characters (1024 bytes) + long_metadata = "FF" * 1025 + with self.assertRaises(XRPLModelException) as error: + MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + mptoken_metadata=long_metadata, + ) + self.assertIn( + "Metadata must be a hex string less than 1024 bytes", + error.exception.args[0], + ) + + def test_tx_mptoken_metadata_not_hex(self): + with self.assertRaises(XRPLModelException) as error: + MPTokenIssuanceSet( + account=_ACCOUNT, + mptoken_issuance_id=_TOKEN_ID, + mptoken_metadata="not_hex_string", + ) + self.assertIn("Metadata must be a valid hex string", error.exception.args[0]) diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index 9fdd5ff6a..fcb313b41 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -680,6 +680,16 @@ "type": "UInt32" } ], + [ + "MutableFlags", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 53, + "type": "UInt32" + } + ], [ "IndexNext", { @@ -2130,6 +2140,16 @@ "type": "Number" } ], + [ + "DummyInt32", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 1, + "type": "Int32" + } + ], [ "TransactionMetaData", { @@ -3276,7 +3296,6 @@ "terLAST": -91, "terNO_ACCOUNT": -96, "terNO_AMM": -87, - "tedADDRESS_COLLISION": -86, "terNO_AUTH": -95, "terNO_LINE": -94, "terNO_RIPPLE": -90, @@ -3342,14 +3361,13 @@ "TicketCreate": 10, "TrustSet": 20, "UNLModify": 102, - "VaultCreate": 65, "VaultClawback": 70, - "VaultDeposit": 68, + "VaultCreate": 65, "VaultDelete": 67, + "VaultDeposit": 68, "VaultSet": 66, "VaultWithdraw": 69, "XChainAccountCreateCommit": 44, - "XChainAddAccountCreateAttestation": 46, "XChainAddClaimAttestation": 45, "XChainClaim": 43, "XChainCommit": 42, @@ -3367,6 +3385,8 @@ "Hash160": 17, "Hash192": 21, "Hash256": 5, + "Int32": 10, + "Int64": 11, "Issue": 24, "LedgerEntry": 10002, "Metadata": 10004, diff --git a/xrpl/models/transactions/__init__.py b/xrpl/models/transactions/__init__.py index 112a0ca75..62ed6c64a 100644 --- a/xrpl/models/transactions/__init__.py +++ b/xrpl/models/transactions/__init__.py @@ -50,12 +50,14 @@ MPTokenIssuanceCreate, MPTokenIssuanceCreateFlag, MPTokenIssuanceCreateFlagInterface, + MPTokenIssuanceCreateMutableFlag, ) from xrpl.models.transactions.mptoken_issuance_destroy import MPTokenIssuanceDestroy from xrpl.models.transactions.mptoken_issuance_set import ( MPTokenIssuanceSet, MPTokenIssuanceSetFlag, MPTokenIssuanceSetFlagInterface, + MPTokenIssuanceSetMutableFlag, ) from xrpl.models.transactions.nftoken_accept_offer import NFTokenAcceptOffer from xrpl.models.transactions.nftoken_burn import NFTokenBurn @@ -172,10 +174,12 @@ "MPTokenIssuanceCreate", "MPTokenIssuanceCreateFlag", "MPTokenIssuanceCreateFlagInterface", + "MPTokenIssuanceCreateMutableFlag", "MPTokenIssuanceDestroy", "MPTokenIssuanceSet", "MPTokenIssuanceSetFlag", "MPTokenIssuanceSetFlagInterface", + "MPTokenIssuanceSetMutableFlag", "NFTokenAcceptOffer", "NFTokenBurn", "NFTokenCancelOffer", diff --git a/xrpl/models/transactions/mptoken_issuance_create.py b/xrpl/models/transactions/mptoken_issuance_create.py index 946200514..b2a59a6f6 100644 --- a/xrpl/models/transactions/mptoken_issuance_create.py +++ b/xrpl/models/transactions/mptoken_issuance_create.py @@ -37,6 +37,38 @@ class MPTokenIssuanceCreateFlag(int, Enum): TF_MPT_CAN_CLAWBACK = 0x00000040 +class MPTokenIssuanceCreateMutableFlag(int, Enum): + """ + MutableFlags for MPTokenIssuanceCreate transaction. + These flags indicate which fields or flags can be modified after issuance. + Prefixed with TMF (Transaction Mutable Flag) to distinguish from TF flags. + """ + + TMF_MPT_CAN_MUTATE_CAN_LOCK = 0x00000002 + """Indicates flag lsfMPTCanLock can be changed""" + + TMF_MPT_CAN_MUTATE_REQUIRE_AUTH = 0x00000004 + """Indicates flag lsfMPTRequireAuth can be changed""" + + TMF_MPT_CAN_MUTATE_CAN_ESCROW = 0x00000008 + """Indicates flag lsfMPTCanEscrow can be changed""" + + TMF_MPT_CAN_MUTATE_CAN_TRADE = 0x00000010 + """Indicates flag lsfMPTCanTrade can be changed""" + + TMF_MPT_CAN_MUTATE_CAN_TRANSFER = 0x00000020 + """Indicates flag lsfMPTCanTransfer can be changed""" + + TMF_MPT_CAN_MUTATE_CAN_CLAWBACK = 0x00000040 + """Indicates flag lsfMPTCanClawback can be changed""" + + TMF_MPT_CAN_MUTATE_METADATA = 0x00010000 + """Allows field MPTokenMetadata to be modified""" + + TMF_MPT_CAN_MUTATE_TRANSFER_FEE = 0x00020000 + """Allows field TransferFee to be modified""" + + class MPTokenIssuanceCreateFlagInterface(TransactionFlagInterface): """ Transactions of the MPTokenIssuanceCreate type support additional values in the @@ -102,6 +134,14 @@ class MPTokenIssuanceCreate(Transaction): may not be discoverable by ecosystem tools such as explorers and indexers. """ + mutable_flags: Optional[int] = None + """ + Indicates specific fields or flags that are mutable after issuance. + This field is optional and only available when the DynamicMPT amendment is enabled. + Use MPTokenIssuanceCreateMutableFlag enum values to specify which fields/flags + can be modified via MPTokenIssuanceSet after creation. + """ + transaction_type: TransactionType = field( default=TransactionType.MPTOKEN_ISSUANCE_CREATE, init=False, @@ -140,4 +180,29 @@ def _get_errors(self: Self) -> Dict[str, str]: ) warnings.warn(message, stacklevel=5) + # Validate mutable_flags (DynamicMPT) + if self.mutable_flags is not None: + # Define all valid mutable flags + valid_mutable_flags = ( + MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_CAN_LOCK.value + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_REQUIRE_AUTH.value + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_CAN_ESCROW.value + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_CAN_TRADE.value + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_CAN_TRANSFER.value + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_CAN_CLAWBACK.value + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_METADATA.value + | MPTokenIssuanceCreateMutableFlag.TMF_MPT_CAN_MUTATE_TRANSFER_FEE.value + ) + + # Check for bits that are NOT in the valid set, + # including the reserved 0x00000001 + if self.mutable_flags & ~valid_mutable_flags: + errors["mutable_flags"] = ( + "mutable_flags contains invalid or reserved bits" + ) + + # Check for zero value + if self.mutable_flags == 0: + errors["mutable_flags"] = "mutable_flags cannot be 0" + return errors diff --git a/xrpl/models/transactions/mptoken_issuance_set.py b/xrpl/models/transactions/mptoken_issuance_set.py index 6d6b0628b..1377eda0d 100644 --- a/xrpl/models/transactions/mptoken_issuance_set.py +++ b/xrpl/models/transactions/mptoken_issuance_set.py @@ -2,16 +2,25 @@ from __future__ import annotations +import warnings from dataclasses import dataclass, field from enum import Enum from typing import Dict, Optional -from typing_extensions import Self +from typing_extensions import Final, Self from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import Transaction, TransactionFlagInterface from xrpl.models.transactions.types import TransactionType -from xrpl.models.utils import require_kwargs_on_init +from xrpl.models.utils import ( + HEX_REGEX, + MAX_MPTOKEN_METADATA_LENGTH, + MPT_META_WARNING_HEADER, + require_kwargs_on_init, + validate_mptoken_metadata, +) + +_MAX_TRANSFER_FEE: Final[int] = 50000 class MPTokenIssuanceSetFlag(int, Enum): @@ -34,6 +43,74 @@ class MPTokenIssuanceSetFlag(int, Enum): """ +class MPTokenIssuanceSetMutableFlag(int, Enum): + """ + MutableFlags for MPTokenIssuanceSet transaction. + These flags are used to set or clear flags that were marked as mutable during + MPTokenIssuanceCreate. Prefixed with TMF (Transaction Mutable Flag). + """ + + TMF_MPT_SET_CAN_LOCK = 0x00000001 + """ + Sets the lsfMPTCanLock flag. Enables the token to be locked both + individually and globally. + """ + + TMF_MPT_CLEAR_CAN_LOCK = 0x00000002 + """ + Clears the lsfMPTCanLock flag. Disables both individual and global + locking of the token. + """ + + TMF_MPT_SET_REQUIRE_AUTH = 0x00000004 + """Sets the lsfMPTRequireAuth flag. Requires individual holders to be authorized.""" + + TMF_MPT_CLEAR_REQUIRE_AUTH = 0x00000008 + """Clears the lsfMPTRequireAuth flag. Holders are not required to be authorized.""" + + TMF_MPT_SET_CAN_ESCROW = 0x00000010 + """Sets the lsfMPTCanEscrow flag. Allows holders to place balances into escrow.""" + + TMF_MPT_CLEAR_CAN_ESCROW = 0x00000020 + """ + Clears the lsfMPTCanEscrow flag. Disallows holders from placing + balances into escrow. + """ + + TMF_MPT_SET_CAN_TRADE = 0x00000040 + """ + Sets the lsfMPTCanTrade flag. Allows holders to trade balances on + the XRPL DEX. + """ + + TMF_MPT_CLEAR_CAN_TRADE = 0x00000080 + """ + Clears the lsfMPTCanTrade flag. Disallows holders from trading + balances on the XRPL DEX. + """ + + TMF_MPT_SET_CAN_TRANSFER = 0x00000100 + """ + Sets the lsfMPTCanTransfer flag. Allows tokens to be transferred to + non-issuer accounts. + """ + + TMF_MPT_CLEAR_CAN_TRANSFER = 0x00000200 + """ + Clears the lsfMPTCanTransfer flag. Disallows transfers to non-issuer + accounts. + """ + + TMF_MPT_SET_CAN_CLAWBACK = 0x00000400 + """ + Sets the lsfMPTCanClawback flag. Enables the issuer to claw back + tokens via Clawback or AMMClawback transactions. + """ + + TMF_MPT_CLEAR_CAN_CLAWBACK = 0x00000800 + """Clears the lsfMPTCanClawback flag. The token cannot be clawed back.""" + + class MPTokenIssuanceSetFlagInterface(TransactionFlagInterface): """ Transactions of the MPTokenIssuanceSet type support additional values in the @@ -51,6 +128,9 @@ class MPTokenIssuanceSet(Transaction): """ The MPTokenIssuanceSet transaction is used to globally lock/unlock a MPTokenIssuance, or lock/unlock an individual's MPToken. + + With the DynamicMPT amendment, this transaction can also be used to update + fields or flags that were marked as mutable during MPTokenIssuanceCreate. """ mptoken_issuance_id: str = REQUIRED @@ -62,6 +142,29 @@ class MPTokenIssuanceSet(Transaction): If omitted, this transaction will apply to all any accounts holding MPTs. """ + mptoken_metadata: Optional[str] = None + """ + New metadata to replace the existing value. Only valid if the MPTokenIssuance + was created with TMF_MPT_CAN_MUTATE_METADATA flag set. + Setting an empty string removes the field. + Requires DynamicMPT amendment. + """ + + transfer_fee: Optional[int] = None + """ + New transfer fee value. Only valid if the MPTokenIssuance was created with + TMF_MPT_CAN_MUTATE_TRANSFER_FEE flag set. + Setting to zero removes the field. + Requires DynamicMPT amendment. + """ + + mutable_flags: Optional[int] = None + """ + Set or clear flags which were marked as mutable during creation. + Use MPTokenIssuanceSetMutableFlag enum values. + Requires DynamicMPT amendment. + """ + transaction_type: TransactionType = field( default=TransactionType.MPTOKEN_ISSUANCE_SET, init=False, @@ -70,6 +173,7 @@ class MPTokenIssuanceSet(Transaction): def _get_errors(self: Self) -> Dict[str, str]: errors = super()._get_errors() + # Original validation for lock/unlock flags if self.has_flag(MPTokenIssuanceSetFlag.TF_MPT_LOCK) and self.has_flag( MPTokenIssuanceSetFlag.TF_MPT_UNLOCK ): @@ -77,4 +181,118 @@ def _get_errors(self: Self) -> Dict[str, str]: "flag conflict: both TF_MPT_LOCK and TF_MPT_UNLOCK can't be set" ) + # DynamicMPT validations + has_dynamic_fields = ( + self.mutable_flags is not None + or self.mptoken_metadata is not None + or self.transfer_fee is not None + ) + + # Check for malformed combinations with holder field + if has_dynamic_fields and self.holder is not None: + errors["holder"] = ( + "holder cannot be provided when mutable_flags, mptoken_metadata, " + "or transfer_fee is present" + ) + + # Check for malformed combinations with Flags field + if has_dynamic_fields and self.flags is not None: + # Flags cannot be used with DynamicMPT fields + if self.flags != 0: + errors["flags"] = ( + "Flags cannot be provided when mutable_flags, " + "mptoken_metadata, or transfer_fee is present" + ) + + # Validate mutable_flags + if self.mutable_flags is not None: + # Check for invalid value (0 is invalid) + if self.mutable_flags == 0: + errors["mutable_flags"] = "mutable_flags cannot be 0" + + # Check for conflicting set/clear flags + flag_pairs = [ + ( + MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_LOCK, + MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_CAN_LOCK, + "CAN_LOCK", + ), + ( + MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_REQUIRE_AUTH, + MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_REQUIRE_AUTH, + "REQUIRE_AUTH", + ), + ( + MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_ESCROW, + MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_CAN_ESCROW, + "CAN_ESCROW", + ), + ( + MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_TRADE, + MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_CAN_TRADE, + "CAN_TRADE", + ), + ( + MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_TRANSFER, + MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_CAN_TRANSFER, + "CAN_TRANSFER", + ), + ( + MPTokenIssuanceSetMutableFlag.TMF_MPT_SET_CAN_CLAWBACK, + MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_CAN_CLAWBACK, + "CAN_CLAWBACK", + ), + ] + + for set_flag, clear_flag, name in flag_pairs: + if (self.mutable_flags & set_flag.value) and ( + self.mutable_flags & clear_flag.value + ): + errors["mutable_flags"] = ( + f"Cannot set and clear {name} flag simultaneously" + ) + break + + # Check for TMF_MPT_CLEAR_CAN_TRANSFER with non-zero transfer_fee + if ( + self.transfer_fee is not None + and self.transfer_fee != 0 + and ( + self.mutable_flags + & MPTokenIssuanceSetMutableFlag.TMF_MPT_CLEAR_CAN_TRANSFER.value + ) + ): + errors["transfer_fee"] = ( + "Cannot include non-zero transfer_fee when clearing " + "CAN_TRANSFER flag" + ) + + # Validate transfer_fee + if self.transfer_fee is not None: + if self.transfer_fee < 0 or self.transfer_fee > _MAX_TRANSFER_FEE: + errors["transfer_fee"] = ( + f"transfer_fee must be between 0 and {_MAX_TRANSFER_FEE}" + ) + + # Validate mptoken_metadata + if self.mptoken_metadata is not None: + # Empty string is allowed (removes the field) + if len(self.mptoken_metadata) > 0: + if len(self.mptoken_metadata) > MAX_MPTOKEN_METADATA_LENGTH: + errors["mptoken_metadata"] = ( + "Metadata must be a hex string less than 1024 bytes " + "(alternatively, 2048 hex characters)." + ) + elif not HEX_REGEX.fullmatch(self.mptoken_metadata): + errors["mptoken_metadata"] = "Metadata must be a valid hex string" + + # Validate metadata format with warnings + validation_messages = validate_mptoken_metadata(self.mptoken_metadata) + if len(validation_messages) > 0: + message = "\n".join( + [MPT_META_WARNING_HEADER] + + [f"- {msg}" for msg in validation_messages] + ) + warnings.warn(message, stacklevel=5) + return errors From bfc8dea1d6af6577d87db107bfaa8beb00dd41cd Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Mon, 20 Oct 2025 12:47:43 -0400 Subject: [PATCH 2/5] Correct ripple epoch time comments since it should be 2000-01-01T00:00Z --- xrpl/utils/time_conversions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/xrpl/utils/time_conversions.py b/xrpl/utils/time_conversions.py index c14bbd2de..9b78678d8 100644 --- a/xrpl/utils/time_conversions.py +++ b/xrpl/utils/time_conversions.py @@ -23,7 +23,7 @@ def ripple_time_to_datetime(ripple_time: int) -> datetime: `_ object. Args: - ripple_time: Whole seconds since the Ripple Epoch of 2001-01-01T00:00Z + ripple_time: Whole seconds since the Ripple Epoch of 2000-01-01T00:00Z Returns: The equivalent time as a ``datetime`` instance. @@ -53,7 +53,7 @@ def datetime_to_ripple_time(dt: datetime) -> int: Returns: The equivalent time in whole seconds since the Ripple Epoch of - 2001-01-01T00:00Z + 2000-01-01T00:00Z Raises: XRPLTimeRangeException: if the time is outside the range that can be @@ -76,7 +76,7 @@ def ripple_time_to_posix(ripple_time: int) -> int: Args: ripple_time: A timestamp as the number of whole seconds since the - Ripple Epoch of 2001-01-01T00:00Z + Ripple Epoch of 2000-01-01T00:00Z Returns: The equivalent time in whole seconds since the UNIX epoch of @@ -108,7 +108,7 @@ def posix_to_ripple_time(timestamp: Union[int, float]) -> int: Returns: The equivalent time in whole seconds since the Ripple Epoch of - 2001-01-01T00:00Z + 2000-01-01T00:00Z Raises: XRPLTimeRangeException: if the time is outside the range that can be From 95a34f95254a36d8eb61ec36b146b8755523dce6 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Mon, 20 Oct 2025 16:24:47 -0400 Subject: [PATCH 3/5] Update PR template to ask writing unit tests for tem* failure conditions for amendment PRs --- .github/pull_request_template.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 701121f0a..76039d661 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -41,6 +41,10 @@ Please check relevant options, delete irrelevant ones. +* Unit tests + * [ ] For PRs to support new amendments, I add unit tests to cover all failure conditions, with error code `tem*`, mentioned in the XLS spec +* Integration tests +* Other tests