From f0a0f585d65ebd9e5e9a1cd750043b8261b707fc Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 23 Jan 2025 17:31:55 +0530 Subject: [PATCH 01/22] models for SAV transactions and requests --- xrpl/models/requests/ledger_entry.py | 4 ++ .../transactions/types/transaction_type.py | 6 +++ xrpl/models/transactions/vault_clawback.py | 28 +++++++++++ xrpl/models/transactions/vault_create.py | 47 +++++++++++++++++++ xrpl/models/transactions/vault_delete.py | 25 ++++++++++ xrpl/models/transactions/vault_deposit.py | 28 +++++++++++ xrpl/models/transactions/vault_set.py | 29 ++++++++++++ xrpl/models/transactions/vault_withdraw.py | 29 ++++++++++++ 8 files changed, 196 insertions(+) create mode 100644 xrpl/models/transactions/vault_clawback.py create mode 100644 xrpl/models/transactions/vault_create.py create mode 100644 xrpl/models/transactions/vault_delete.py create mode 100644 xrpl/models/transactions/vault_deposit.py create mode 100644 xrpl/models/transactions/vault_set.py create mode 100644 xrpl/models/transactions/vault_withdraw.py diff --git a/xrpl/models/requests/ledger_entry.py b/xrpl/models/requests/ledger_entry.py index 4499d9e83..637f0a1ad 100644 --- a/xrpl/models/requests/ledger_entry.py +++ b/xrpl/models/requests/ledger_entry.py @@ -39,6 +39,7 @@ class LedgerEntryType(str, Enum): ORACLE = "oracle" PAYMENT_CHANNEL = "payment_channel" SIGNER_LIST = "signer_list" + SINGLE_ASSET_VAULT = "vault_id" STATE = "state" TICKET = "ticket" MPT_ISSUANCE = "mpt_issuance" @@ -300,6 +301,8 @@ class LedgerEntry(Request, LookupByLedgerRequest): oracle: Optional[Oracle] = None payment_channel: Optional[str] = None ripple_state: Optional[RippleState] = None + # Single Asset Vault ledger-object can be retrieved by its index only + vault_id: Optional[str] = None ticket: Optional[Union[str, Ticket]] = None bridge_account: Optional[str] = None bridge: Optional[XChainBridge] = None @@ -333,6 +336,7 @@ def _get_errors(self: Self) -> Dict[str, str]: self.oracle, self.payment_channel, self.ripple_state, + self.vault_id, self.ticket, self.xchain_claim_id, self.xchain_create_account_claim_id, diff --git a/xrpl/models/transactions/types/transaction_type.py b/xrpl/models/transactions/types/transaction_type.py index 1d2119a90..4cbe9680c 100644 --- a/xrpl/models/transactions/types/transaction_type.py +++ b/xrpl/models/transactions/types/transaction_type.py @@ -48,6 +48,12 @@ class TransactionType(str, Enum): SIGNER_LIST_SET = "SignerListSet" TICKET_CREATE = "TicketCreate" TRUST_SET = "TrustSet" + VAULT_CREATE = "VaultCreate" + VAULT_CLAWBACK = "VaultClawback" + VAULT_DEPOSIT = "VaultDeposit" + VAULT_DELETE = "VaultDelete" + VAULT_SET = "VaultSet" + VAULT_WITHDRAW = "VaultWithdraw" XCHAIN_ACCOUNT_CREATE_COMMIT = "XChainAccountCreateCommit" XCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION = "XChainAddAccountCreateAttestation" XCHAIN_ADD_CLAIM_ATTESTATION = "XChainAddClaimAttestation" diff --git a/xrpl/models/transactions/vault_clawback.py b/xrpl/models/transactions/vault_clawback.py new file mode 100644 index 000000000..342925f6b --- /dev/null +++ b/xrpl/models/transactions/vault_clawback.py @@ -0,0 +1,28 @@ +""" +Represents a VaultClawback transaction on the XRP Ledger. +""" + +from dataclasses import dataclass, field +from typing import Optional, Union + +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class VaultClawback(Transaction): + """ + Represents a VaultClawback transaction on the XRP Ledger. + """ + + vault_id: str = REQUIRED # type: ignore + holder: str = REQUIRED # type: ignore + amount: Optional[int] = None + + transaction_type: TransactionType = field( + default=TransactionType.VAULT_CLAWBACK, + init=False, + ) diff --git a/xrpl/models/transactions/vault_create.py b/xrpl/models/transactions/vault_create.py new file mode 100644 index 000000000..5b9de194f --- /dev/null +++ b/xrpl/models/transactions/vault_create.py @@ -0,0 +1,47 @@ +""" +Represents a VaultCreate transaction on the XRP Ledger. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, Union + +from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.models.flags import FlagInterface +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +class VaultCreateFlag(int, Enum): + + TF_VAULT_PRIVATE = 0x0001 + TF_VAULT_SHARE_NON_TRANSFERABLE = 0x0002 + + +class VaultCreateFlagInterface(FlagInterface): + + TF_VAULT_PRIVATE: bool + TF_VAULT_SHARE_NON_TRANSFERABLE: bool + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class VaultCreate(Transaction): + """ + Represents a VaultCreate transaction on the XRP Ledger. + """ + + data: Optional[str] = None + # Keshava: Is this an accurate representation of the asset object type, does it + # capture MPT? + asset: Union[str, IssuedCurrencyAmount] = REQUIRED # type: ignore + asset_maximum: Optional[str] = None + mptoken_metadata: Optional[str] = None + permissioned_domain_id: Optional[str] = None + + transaction_type: TransactionType = field( + default=TransactionType.VAULT_CREATE, + init=False, + ) diff --git a/xrpl/models/transactions/vault_delete.py b/xrpl/models/transactions/vault_delete.py new file mode 100644 index 000000000..4d690badf --- /dev/null +++ b/xrpl/models/transactions/vault_delete.py @@ -0,0 +1,25 @@ +""" +Represents a VaultDelete transaction on the XRP Ledger. +""" + +from dataclasses import dataclass, field + +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class VaultDelete(Transaction): + """ + Represents a VaultDelete transaction on the XRP Ledger. + """ + + vault_id: str = REQUIRED # type: ignore + + transaction_type: TransactionType = field( + default=TransactionType.VAULT_DELETE, + init=False, + ) diff --git a/xrpl/models/transactions/vault_deposit.py b/xrpl/models/transactions/vault_deposit.py new file mode 100644 index 000000000..ca1882bcb --- /dev/null +++ b/xrpl/models/transactions/vault_deposit.py @@ -0,0 +1,28 @@ +""" +Represents a VaultDeposit transaction on the XRP Ledger. +""" + +from dataclasses import dataclass, field +from typing import Union + +from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class VaultDeposit(Transaction): + """ + Represents a VaultDeposit transaction on the XRP Ledger. + """ + + vault_id: str = REQUIRED # type: ignore + amount: Union[str, IssuedCurrencyAmount] = REQUIRED # type: ignore + + transaction_type: TransactionType = field( + default=TransactionType.VAULT_DEPOSIT, + init=False, + ) diff --git a/xrpl/models/transactions/vault_set.py b/xrpl/models/transactions/vault_set.py new file mode 100644 index 000000000..03eab3162 --- /dev/null +++ b/xrpl/models/transactions/vault_set.py @@ -0,0 +1,29 @@ +""" +Represents a VaultSet transaction on the XRP Ledger. +""" + +from dataclasses import dataclass, field +from typing import Optional + +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class VaultSet(Transaction): + """ + Represents a VaultSet transaction on the XRP Ledger. + """ + + vault_id: str = REQUIRED # type: ignore + domain_id: Optional[str] = None + data: Optional[str] = None + asset_maximum: Optional[str] = None + + transaction_type: TransactionType = field( + default=TransactionType.VAULT_SET, + init=False, + ) diff --git a/xrpl/models/transactions/vault_withdraw.py b/xrpl/models/transactions/vault_withdraw.py new file mode 100644 index 000000000..3e92a5e16 --- /dev/null +++ b/xrpl/models/transactions/vault_withdraw.py @@ -0,0 +1,29 @@ +""" +Represents a VaultWithdraw transaction on the XRP Ledger. +""" + +from dataclasses import dataclass, field +from typing import Optional, Union + +from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class VaultWithdraw(Transaction): + """ + Represents a VaultWithdraw transaction on the XRP Ledger. + """ + + vault_id: str = REQUIRED # type: ignore + amount: Union[str, IssuedCurrencyAmount] = REQUIRED # type: ignore + destination: Optional[str] = None + + transaction_type: TransactionType = field( + default=TransactionType.VAULT_WITHDRAW, + init=False, + ) From e4e9ec55042f1410c7520d942793d04ca6cd1266 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Fri, 24 Jan 2025 21:10:45 +0530 Subject: [PATCH 02/22] initial framework of integration tests; definitions file --- .ci-config/rippled.cfg | 1 + tests/integration/transactions/test_sav.py | 41 +++++++++++++ .../binarycodec/definitions/definitions.json | 60 ++++++++++++++++++- xrpl/models/transactions/__init__.py | 12 ++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/integration/transactions/test_sav.py diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index 3e57aaf0a..df89a1097 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -194,6 +194,7 @@ fixNFTokenPageLinks fixInnerObjTemplate2 fixEnforceNFTokenTrustline fixReducedOffersV2 +SingleAssetVault # This section can be used to simulate various FeeSettings scenarios for rippled node in standalone mode [voting] diff --git a/tests/integration/transactions/test_sav.py b/tests/integration/transactions/test_sav.py new file mode 100644 index 000000000..7e56f059e --- /dev/null +++ b/tests/integration/transactions/test_sav.py @@ -0,0 +1,41 @@ +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 import VaultCreate +from xrpl.models.response import ResponseStatus + + +class TestSingleAssetVault(IntegrationTestCase): + @test_async_and_sync(globals()) + async def test_sav_lifecycle(self, client): + + # Create a vault + tx = VaultCreate( + account=WALLET.address, + asset="100", + asset_maximum="1000", + ) + response = await sign_and_reliable_submission_async(tx, WALLET, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Verify the existence of the vault with account_objects RPC call + + # Update the characteristics of the vault with VaultSet transaction + + # Execute a VaultDeposit transaction + + # Execute a VaultWithdraw transaction + + # Execute a VaultClawback transaction + + # Delete the Vault with VaultDelete transaction + + # # confirm that the DID was actually created + # account_objects_response = await client.request( + # AccountObjects(account=WALLET.address, type=AccountObjectType.DID) + # ) + # self.assertEqual(len(account_objects_response.result["account_objects"]), 1) diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index c6940aba8..279208b1d 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -1250,6 +1250,16 @@ "type": "Hash256" } ], + [ + "VaultID", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 35, + "type": "Hash256" + } + ], [ "hash", { @@ -2020,6 +2030,46 @@ "type": "AccountID" } ], + [ + "AssetAvailable", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 2, + "type": "Number" + } + ], + [ + "AssetMaximum", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 3, + "type": "Number" + } + ], + [ + "AssetTotal", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 4, + "type": "Number" + } + ], + [ + "LossUnrealized", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 5, + "type": "Number" + } + ], [ "TransactionMetaData", { @@ -2866,6 +2916,7 @@ "RippleState": 114, "SignerList": 83, "Ticket": 84, + "Vault" : 131, "XChainOwnedClaimID": 113, "XChainOwnedCreateAccountClaimID": 116 }, @@ -3099,6 +3150,12 @@ "TicketCreate": 10, "TrustSet": 20, "UNLModify": 102, + "VaultCreate": 64, + "VaultClawback": 69, + "VaultDeposit": 67, + "VaultDelete": 66, + "VaultSet": 65, + "VaultWithdraw": 68, "XChainAccountCreateCommit": 44, "XChainAddAccountCreateAttestation": 46, "XChainAddClaimAttestation": 45, @@ -3122,6 +3179,7 @@ "LedgerEntry": 10002, "Metadata": 10004, "NotPresent": 0, + "Number": 9, "PathSet": 18, "STArray": 15, "STObject": 14, @@ -3138,4 +3196,4 @@ "Vector256": 19, "XChainBridge": 25 } -} \ No newline at end of file +} diff --git a/xrpl/models/transactions/__init__.py b/xrpl/models/transactions/__init__.py index e59363d0d..c1b6a940a 100644 --- a/xrpl/models/transactions/__init__.py +++ b/xrpl/models/transactions/__init__.py @@ -92,6 +92,12 @@ TrustSetFlag, TrustSetFlagInterface, ) +from xrpl.models.transactions.vault_clawback import VaultClawback +from xrpl.models.transactions.vault_create import VaultCreate +from xrpl.models.transactions.vault_delete import VaultDelete +from xrpl.models.transactions.vault_deposit import VaultDeposit +from xrpl.models.transactions.vault_set import VaultSet +from xrpl.models.transactions.vault_withdraw import VaultWithdraw from xrpl.models.transactions.xchain_account_create_commit import ( XChainAccountCreateCommit, ) @@ -185,6 +191,12 @@ "TrustSet", "TrustSetFlag", "TrustSetFlagInterface", + "VaultClawback", + "VaultCreate", + "VaultDelete", + "VaultDeposit", + "VaultSet", + "VaultWithdraw", "XChainAccountCreateCommit", "XChainAddAccountCreateAttestation", "XChainAddClaimAttestation", From 5d015a3f192ee40394ad49d01c1aee024027e107 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 4 Feb 2025 14:31:20 -0800 Subject: [PATCH 03/22] provide MPT support for SAV transaction fields --- .../binarycodec/definitions/definitions.json | 20 +++++++++++++ xrpl/core/binarycodec/types/number.py | 30 +++++++++++++++++++ xrpl/models/transactions/vault_clawback.py | 5 ++-- xrpl/models/transactions/vault_create.py | 8 ++--- xrpl/models/transactions/vault_deposit.py | 5 ++-- xrpl/models/transactions/vault_withdraw.py | 6 ++-- 6 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 xrpl/core/binarycodec/types/number.py diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index 279208b1d..74d98d069 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -1250,6 +1250,16 @@ "type": "Hash256" } ], + [ + "DomainID", + { + "isSerialized" : true, + "isSigningField" : true, + "isVLEncoded" : false, + "nth" : 34, + "type" : "Hash256" + } + ], [ "VaultID", { @@ -2030,6 +2040,16 @@ "type": "AccountID" } ], + [ + "Number", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 1, + "type": "Number" + } + ], [ "AssetAvailable", { diff --git a/xrpl/core/binarycodec/types/number.py b/xrpl/core/binarycodec/types/number.py new file mode 100644 index 000000000..c0a29f29a --- /dev/null +++ b/xrpl/core/binarycodec/types/number.py @@ -0,0 +1,30 @@ +from typing import TYPE_CHECKING, Any, Dict, Optional, Type + +from typing_extensions import Self + +from xrpl.core.binarycodec.binary_wrappers.binary_parser import BinaryParser +from xrpl.core.binarycodec.types.serialized_type import SerializedType + + +class Number(SerializedType): + """Codec for serializing and deserializing Number fields.""" + + def __init__(self: Self, buffer: bytes) -> None: + """Construct a Number from given bytes.""" + super().__init__(buffer) + + @classmethod + def from_parser( # noqa: D102 + cls: Type[Self], + parser: BinaryParser, + # length_hint is Any so that subclasses can choose whether or not to require it. + length_hint: Any, # noqa: ANN401 + ) -> Self: + pass + + @classmethod + def from_value(cls: Type[Self], value: Dict[str, Any]) -> Self: + return cls(bytes(value)) + + # def to_json(self: Self) -> Dict[str, Any]: + # pass diff --git a/xrpl/models/transactions/vault_clawback.py b/xrpl/models/transactions/vault_clawback.py index 342925f6b..f0660c481 100644 --- a/xrpl/models/transactions/vault_clawback.py +++ b/xrpl/models/transactions/vault_clawback.py @@ -3,8 +3,9 @@ """ from dataclasses import dataclass, field -from typing import Optional, Union +from typing import Optional +from xrpl.models.amounts import Amount from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import Transaction from xrpl.models.transactions.types import TransactionType @@ -20,7 +21,7 @@ class VaultClawback(Transaction): vault_id: str = REQUIRED # type: ignore holder: str = REQUIRED # type: ignore - amount: Optional[int] = None + amount: Optional[Amount] = None transaction_type: TransactionType = field( default=TransactionType.VAULT_CLAWBACK, diff --git a/xrpl/models/transactions/vault_create.py b/xrpl/models/transactions/vault_create.py index 5b9de194f..1ba3db60c 100644 --- a/xrpl/models/transactions/vault_create.py +++ b/xrpl/models/transactions/vault_create.py @@ -4,9 +4,9 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Optional, Union +from typing import Optional -from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.models.amounts import Amount from xrpl.models.flags import FlagInterface from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import Transaction @@ -34,9 +34,7 @@ class VaultCreate(Transaction): """ data: Optional[str] = None - # Keshava: Is this an accurate representation of the asset object type, does it - # capture MPT? - asset: Union[str, IssuedCurrencyAmount] = REQUIRED # type: ignore + asset: Amount = REQUIRED # type: ignore asset_maximum: Optional[str] = None mptoken_metadata: Optional[str] = None permissioned_domain_id: Optional[str] = None diff --git a/xrpl/models/transactions/vault_deposit.py b/xrpl/models/transactions/vault_deposit.py index ca1882bcb..6281eef33 100644 --- a/xrpl/models/transactions/vault_deposit.py +++ b/xrpl/models/transactions/vault_deposit.py @@ -3,9 +3,8 @@ """ from dataclasses import dataclass, field -from typing import Union -from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.models.amounts import Amount from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import Transaction from xrpl.models.transactions.types import TransactionType @@ -20,7 +19,7 @@ class VaultDeposit(Transaction): """ vault_id: str = REQUIRED # type: ignore - amount: Union[str, IssuedCurrencyAmount] = REQUIRED # type: ignore + amount: Amount = REQUIRED # type: ignore transaction_type: TransactionType = field( default=TransactionType.VAULT_DEPOSIT, diff --git a/xrpl/models/transactions/vault_withdraw.py b/xrpl/models/transactions/vault_withdraw.py index 3e92a5e16..db45acdc8 100644 --- a/xrpl/models/transactions/vault_withdraw.py +++ b/xrpl/models/transactions/vault_withdraw.py @@ -3,9 +3,9 @@ """ from dataclasses import dataclass, field -from typing import Optional, Union +from typing import Optional -from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.models.amounts import Amount from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import Transaction from xrpl.models.transactions.types import TransactionType @@ -20,7 +20,7 @@ class VaultWithdraw(Transaction): """ vault_id: str = REQUIRED # type: ignore - amount: Union[str, IssuedCurrencyAmount] = REQUIRED # type: ignore + amount: Amount = REQUIRED # type: ignore destination: Optional[str] = None transaction_type: TransactionType = field( From 6ea396570564dfd359dd57f6523674b213df434a Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 4 Feb 2025 14:32:28 -0800 Subject: [PATCH 04/22] Test extreme values in the serialzation of STAmount --- tests/unit/core/binarycodec/types/test_amount.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/core/binarycodec/types/test_amount.py b/tests/unit/core/binarycodec/types/test_amount.py index 00246d377..72addd03c 100644 --- a/tests/unit/core/binarycodec/types/test_amount.py +++ b/tests/unit/core/binarycodec/types/test_amount.py @@ -108,6 +108,17 @@ def test_assert_xrp_is_valid_passes(self): amount.verify_xrp_value(valid_zero) amount.verify_xrp_value(valid_amount) + # Note: these values are obtained from the following rippled unit test: + # https://github.com/XRPLF/rippled/blob/33e1c42599857336d792effc753795911bdb13f0/src/test/protocol/STAmount_test.cpp#L513 + # However, the limiting values allowed by the STAmount type are much higher and + # smaller. + def test_large_small_values(self): + small_value = "5499999999999999e-95" + large_value = "15499999999999999e79" + + amount.verify_xrp_value(small_value) + amount.verify_xrp_value(large_value) + def test_assert_xrp_is_valid_raises(self): invalid_amount_large = "1e20" invalid_amount_small = "1e-7" From f06f9095d1e08848bacde2b4de0b2be448dec745 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 4 Feb 2025 14:38:53 -0800 Subject: [PATCH 05/22] Problems in STAmount: Unable to (de)serialize extreme values --- tests/unit/core/binarycodec/types/test_amount.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/core/binarycodec/types/test_amount.py b/tests/unit/core/binarycodec/types/test_amount.py index 72addd03c..c0fa4f2d1 100644 --- a/tests/unit/core/binarycodec/types/test_amount.py +++ b/tests/unit/core/binarycodec/types/test_amount.py @@ -114,7 +114,13 @@ def test_assert_xrp_is_valid_passes(self): # smaller. def test_large_small_values(self): small_value = "5499999999999999e-95" + + serialized_representation = amount.Amount.from_value(small_value) + self.assertEqual(amount.Amount.to_json(serialized_representation), small_value) + large_value = "15499999999999999e79" + serialized_representation = amount.Amount.from_value(large_value) + self.assertEqual(amount.Amount.to_json(serialized_representation), large_value) amount.verify_xrp_value(small_value) amount.verify_xrp_value(large_value) From 2858df22ec4a50b5f3736834a0a93df94fb0fa90 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 4 Feb 2025 15:59:37 -0800 Subject: [PATCH 06/22] [WIP] use struct library to pack Number data --- .../core/binarycodec/types/test_number.py | 9 +++++++ xrpl/core/binarycodec/types/__init__.py | 2 ++ xrpl/core/binarycodec/types/number.py | 26 ++++++++++++------- 3 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 tests/unit/core/binarycodec/types/test_number.py diff --git a/tests/unit/core/binarycodec/types/test_number.py b/tests/unit/core/binarycodec/types/test_number.py new file mode 100644 index 000000000..2a5e815bd --- /dev/null +++ b/tests/unit/core/binarycodec/types/test_number.py @@ -0,0 +1,9 @@ +import unittest + +from xrpl.core.binarycodec.types.number import Number + + +class TestNumber(unittest.TestCase): + def test_serialization_and_deserialization(self): + number_bytes = Number.from_value(124) + self.assertEqual(Number(number_bytes).to_json(), "124") diff --git a/xrpl/core/binarycodec/types/__init__.py b/xrpl/core/binarycodec/types/__init__.py index f2dedab06..66e14cc0d 100644 --- a/xrpl/core/binarycodec/types/__init__.py +++ b/xrpl/core/binarycodec/types/__init__.py @@ -10,6 +10,7 @@ from xrpl.core.binarycodec.types.hash192 import Hash192 from xrpl.core.binarycodec.types.hash256 import Hash256 from xrpl.core.binarycodec.types.issue import Issue +from xrpl.core.binarycodec.types.number import Number from xrpl.core.binarycodec.types.path_set import PathSet from xrpl.core.binarycodec.types.st_array import STArray from xrpl.core.binarycodec.types.st_object import STObject @@ -32,6 +33,7 @@ "Hash192", "Hash256", "Issue", + "Number", "PathSet", "STObject", "STArray", diff --git a/xrpl/core/binarycodec/types/number.py b/xrpl/core/binarycodec/types/number.py index c0a29f29a..e7cf324d0 100644 --- a/xrpl/core/binarycodec/types/number.py +++ b/xrpl/core/binarycodec/types/number.py @@ -1,8 +1,10 @@ -from typing import TYPE_CHECKING, Any, Dict, Optional, Type +import struct +from typing import Optional, Type from typing_extensions import Self from xrpl.core.binarycodec.binary_wrappers.binary_parser import BinaryParser +from xrpl.core.binarycodec.exceptions import XRPLBinaryCodecException from xrpl.core.binarycodec.types.serialized_type import SerializedType @@ -17,14 +19,20 @@ def __init__(self: Self, buffer: bytes) -> None: def from_parser( # noqa: D102 cls: Type[Self], parser: BinaryParser, - # length_hint is Any so that subclasses can choose whether or not to require it. - length_hint: Any, # noqa: ANN401 + length_hint: Optional[int] = None, # noqa: ANN401 ) -> Self: - pass + # Number type consists of two cpp std::uint_64t (mantissa) and + # std::uint_32t (exponent) types which are 8 bytes and 4 bytes respectively + return cls(parser.read(12)) @classmethod - def from_value(cls: Type[Self], value: Dict[str, Any]) -> Self: - return cls(bytes(value)) - - # def to_json(self: Self) -> Dict[str, Any]: - # pass + def from_value(cls: Type[Self], value: str) -> Self: + return cls(struct.pack(">d", float(value))) + + def to_json(self: Self) -> str: + unpack_elems = struct.unpack(">d", self.buffer) + if len(unpack_elems) != 1: + raise XRPLBinaryCodecException( + "Deserialization of Number type did not produce exactly one element" + ) + return str(unpack_elems[0]) From ea1ef24f023e45b5a8cb5d4b7e22f6104664a7dc Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 27 Feb 2025 14:33:26 -0800 Subject: [PATCH 07/22] [WIP] VaultClawback integ tests need to be completed; Errors regarding serialization of Number need to be solved; --- tests/integration/transactions/test_sav.py | 98 ++++++++++++++++--- .../core/binarycodec/types/test_number.py | 8 +- xrpl/asyncio/transaction/main.py | 1 + xrpl/models/requests/account_objects.py | 1 + xrpl/models/transactions/vault_create.py | 4 +- 5 files changed, 95 insertions(+), 17 deletions(-) diff --git a/tests/integration/transactions/test_sav.py b/tests/integration/transactions/test_sav.py index 7e56f059e..0c7ee29d7 100644 --- a/tests/integration/transactions/test_sav.py +++ b/tests/integration/transactions/test_sav.py @@ -1,41 +1,109 @@ from tests.integration.integration_test_case import IntegrationTestCase from tests.integration.it_utils import ( + fund_wallet_async, sign_and_reliable_submission_async, test_async_and_sync, ) from tests.integration.reusable_values import WALLET -from xrpl.models import VaultCreate +from xrpl.models import ( + TrustSet, + VaultCreate, + VaultDelete, + VaultDeposit, + VaultSet, + VaultWithdraw, +) +from xrpl.models.amounts.issued_currency_amount import IssuedCurrencyAmount +from xrpl.models.currencies import XRP, IssuedCurrency +from xrpl.models.requests import AccountObjects +from xrpl.models.requests.account_objects import AccountObjectType from xrpl.models.response import ResponseStatus +from xrpl.utils import str_to_hex +from xrpl.wallet import Wallet class TestSingleAssetVault(IntegrationTestCase): @test_async_and_sync(globals()) async def test_sav_lifecycle(self, client): - # Create a vault + vault_owner = Wallet.create() + await fund_wallet_async(vault_owner) + + # # Prerequisites: Set up the IOU trust lines + # tx = TrustSet( + # account=vault_owner.address, + # limit_amount=IssuedCurrencyAmount( + # currency="USD", issuer=WALLET.address, value="1000" + # ), + # ) + # response = await sign_and_reliable_submission_async(tx, vault_owner, client) + # self.assertEqual(response.status, ResponseStatus.SUCCESS) + # self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-1: Create a vault tx = VaultCreate( - account=WALLET.address, - asset="100", - asset_maximum="1000", + account=vault_owner.address, + asset=XRP(), + # asset=IssuedCurrency(currency="USD", issuer=WALLET.address), + # TODO: This throws a Number::normalize 1 exception in rippled, why ?? + # Possible errors in serialization of Number type + # asset_maximum="1000", ) - response = await sign_and_reliable_submission_async(tx, WALLET, client) + response = await sign_and_reliable_submission_async(tx, vault_owner, client) self.assertEqual(response.status, ResponseStatus.SUCCESS) self.assertEqual(response.result["engine_result"], "tesSUCCESS") # Verify the existence of the vault with account_objects RPC call + account_objects_response = await client.request( + AccountObjects(account=vault_owner.address, type=AccountObjectType.VAULT) + ) + self.assertEqual(len(account_objects_response.result["account_objects"]), 1) - # Update the characteristics of the vault with VaultSet transaction + VAULT_ID = account_objects_response.result["account_objects"][0]["index"] - # Execute a VaultDeposit transaction + # Step-2: Update the characteristics of the vault with VaultSet transaction + tx = VaultSet( + account=vault_owner.address, + vault_id=VAULT_ID, + data=str_to_hex("auxilliary data pertaining to the vault"), + ) + response = await sign_and_reliable_submission_async(tx, vault_owner, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-3: Execute a VaultDeposit transaction + tx = VaultDeposit( + account=WALLET.address, + vault_id=VAULT_ID, + amount="10", + # amount=IssuedCurrencyAmount( + # currency="USD", issuer=WALLET.address, value="10" + # ), + ) + response = await sign_and_reliable_submission_async(tx, WALLET, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") # Execute a VaultWithdraw transaction + tx = VaultWithdraw( + account=WALLET.address, + vault_id=VAULT_ID, + amount="10", + # amount=IssuedCurrencyAmount( + # currency="USD", issuer=WALLET.address, value="10" + # ), + ) + response = await sign_and_reliable_submission_async(tx, WALLET, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") - # Execute a VaultClawback transaction + # TODO: Execute a VaultClawback transaction # Delete the Vault with VaultDelete transaction - - # # confirm that the DID was actually created - # account_objects_response = await client.request( - # AccountObjects(account=WALLET.address, type=AccountObjectType.DID) - # ) - # self.assertEqual(len(account_objects_response.result["account_objects"]), 1) + tx = VaultDelete( + account=vault_owner.address, + vault_id=account_objects_response.result["account_objects"][0]["index"], + ) + response = await sign_and_reliable_submission_async(tx, vault_owner, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") diff --git a/tests/unit/core/binarycodec/types/test_number.py b/tests/unit/core/binarycodec/types/test_number.py index 2a5e815bd..fb9898308 100644 --- a/tests/unit/core/binarycodec/types/test_number.py +++ b/tests/unit/core/binarycodec/types/test_number.py @@ -6,4 +6,10 @@ class TestNumber(unittest.TestCase): def test_serialization_and_deserialization(self): number_bytes = Number.from_value(124) - self.assertEqual(Number(number_bytes).to_json(), "124") + self.assertEqual((number_bytes).to_json(), "124.0") + + number_bytes = Number.from_value(0) + self.assertEqual((number_bytes).to_json(), "0.0") + + number_bytes = Number.from_value(-10) + self.assertEqual((number_bytes).to_json(), "-10.0") diff --git a/xrpl/asyncio/transaction/main.py b/xrpl/asyncio/transaction/main.py index a9f0f6f9c..48eabe276 100644 --- a/xrpl/asyncio/transaction/main.py +++ b/xrpl/asyncio/transaction/main.py @@ -483,6 +483,7 @@ async def _calculate_fee_per_transaction_type( if transaction.transaction_type in ( TransactionType.ACCOUNT_DELETE, TransactionType.AMM_CREATE, + TransactionType.VAULT_CREATE, ): base_fee = await _fetch_owner_reserve_fee(client) diff --git a/xrpl/models/requests/account_objects.py b/xrpl/models/requests/account_objects.py index 2ce9f1798..bd41cae4c 100644 --- a/xrpl/models/requests/account_objects.py +++ b/xrpl/models/requests/account_objects.py @@ -36,6 +36,7 @@ class AccountObjectType(str, Enum): SIGNER_LIST = "signer_list" STATE = "state" TICKET = "ticket" + VAULT = "vault" XCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID = "xchain_owned_create_account_claim_id" XCHAIN_OWNED_CLAIM_ID = "xchain_owned_claim_id" diff --git a/xrpl/models/transactions/vault_create.py b/xrpl/models/transactions/vault_create.py index 1ba3db60c..3919ac24e 100644 --- a/xrpl/models/transactions/vault_create.py +++ b/xrpl/models/transactions/vault_create.py @@ -7,6 +7,7 @@ from typing import Optional from xrpl.models.amounts import Amount +from xrpl.models.currencies import Currency from xrpl.models.flags import FlagInterface from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import Transaction @@ -34,7 +35,8 @@ class VaultCreate(Transaction): """ data: Optional[str] = None - asset: Amount = REQUIRED # type: ignore + # Keshava: TODO: Include MPT Issue in Asset field + asset: Currency = REQUIRED # type: ignore asset_maximum: Optional[str] = None mptoken_metadata: Optional[str] = None permissioned_domain_id: Optional[str] = None From 6966ae71c18dbc363dfa58187d873eb4bfa1ecbe Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 27 Feb 2025 15:53:13 -0800 Subject: [PATCH 08/22] add integ test for VaultClawback transaction --- tests/integration/transactions/test_sav.py | 77 +++++++++++++++------- 1 file changed, 52 insertions(+), 25 deletions(-) diff --git a/tests/integration/transactions/test_sav.py b/tests/integration/transactions/test_sav.py index 0c7ee29d7..0496e13e7 100644 --- a/tests/integration/transactions/test_sav.py +++ b/tests/integration/transactions/test_sav.py @@ -6,7 +6,9 @@ ) from tests.integration.reusable_values import WALLET from xrpl.models import ( + Payment, TrustSet, + VaultClawback, VaultCreate, VaultDelete, VaultDeposit, @@ -14,7 +16,7 @@ VaultWithdraw, ) from xrpl.models.amounts.issued_currency_amount import IssuedCurrencyAmount -from xrpl.models.currencies import XRP, IssuedCurrency +from xrpl.models.currencies import IssuedCurrency from xrpl.models.requests import AccountObjects from xrpl.models.requests.account_objects import AccountObjectType from xrpl.models.response import ResponseStatus @@ -29,22 +31,36 @@ async def test_sav_lifecycle(self, client): vault_owner = Wallet.create() await fund_wallet_async(vault_owner) - # # Prerequisites: Set up the IOU trust lines - # tx = TrustSet( - # account=vault_owner.address, - # limit_amount=IssuedCurrencyAmount( - # currency="USD", issuer=WALLET.address, value="1000" - # ), - # ) - # response = await sign_and_reliable_submission_async(tx, vault_owner, client) - # self.assertEqual(response.status, ResponseStatus.SUCCESS) - # self.assertEqual(response.result["engine_result"], "tesSUCCESS") + issuer_wallet = Wallet.create() + await fund_wallet_async(issuer_wallet) + + # Step-0.a: Prerequisites: Set up the IOU trust line + tx = TrustSet( + account=WALLET.address, + limit_amount=IssuedCurrencyAmount( + currency="USD", issuer=issuer_wallet.address, value="1000" + ), + ) + response = await sign_and_reliable_submission_async(tx, WALLET, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-0.b: Send the payment of IOUs from issuer_wallet to WALLET + tx = Payment( + account=issuer_wallet.address, + amount=IssuedCurrencyAmount( + currency="USD", issuer=issuer_wallet.address, value="1000" + ), + destination=WALLET.address, + ) + response = await sign_and_reliable_submission_async(tx, issuer_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") # Step-1: Create a vault tx = VaultCreate( account=vault_owner.address, - asset=XRP(), - # asset=IssuedCurrency(currency="USD", issuer=WALLET.address), + asset=IssuedCurrency(currency="USD", issuer=issuer_wallet.address), # TODO: This throws a Number::normalize 1 exception in rippled, why ?? # Possible errors in serialization of Number type # asset_maximum="1000", @@ -75,34 +91,45 @@ async def test_sav_lifecycle(self, client): tx = VaultDeposit( account=WALLET.address, vault_id=VAULT_ID, - amount="10", - # amount=IssuedCurrencyAmount( - # currency="USD", issuer=WALLET.address, value="10" - # ), + amount=IssuedCurrencyAmount( + currency="USD", issuer=issuer_wallet.address, value="10" + ), ) response = await sign_and_reliable_submission_async(tx, WALLET, client) self.assertEqual(response.status, ResponseStatus.SUCCESS) self.assertEqual(response.result["engine_result"], "tesSUCCESS") - # Execute a VaultWithdraw transaction + # Step-4: Execute a VaultWithdraw transaction tx = VaultWithdraw( account=WALLET.address, vault_id=VAULT_ID, - amount="10", - # amount=IssuedCurrencyAmount( - # currency="USD", issuer=WALLET.address, value="10" - # ), + amount=IssuedCurrencyAmount( + currency="USD", issuer=issuer_wallet.address, value="9" + ), ) response = await sign_and_reliable_submission_async(tx, WALLET, client) self.assertEqual(response.status, ResponseStatus.SUCCESS) self.assertEqual(response.result["engine_result"], "tesSUCCESS") - # TODO: Execute a VaultClawback transaction + # Step-5: Execute a VaultClawback transaction from issuer_wallet + tx = VaultClawback( + holder=WALLET.address, + account=issuer_wallet.address, + vault_id=VAULT_ID, + # Note: Although the amount is specified as 9, 1 unit of the IOU will be + # clawed back, because that is the remaining balance in the vault + amount=IssuedCurrencyAmount( + currency="USD", issuer=issuer_wallet.address, value="9" + ), + ) + response = await sign_and_reliable_submission_async(tx, issuer_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") - # Delete the Vault with VaultDelete transaction + # Step-6: Delete the Vault with VaultDelete transaction tx = VaultDelete( account=vault_owner.address, - vault_id=account_objects_response.result["account_objects"][0]["index"], + vault_id=VAULT_ID, ) response = await sign_and_reliable_submission_async(tx, vault_owner, client) self.assertEqual(response.status, ResponseStatus.SUCCESS) From 24b4df0d0bc63817c03aeb72d47122978578e15c Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 4 Mar 2025 15:51:13 -0800 Subject: [PATCH 09/22] Enforce proper serialization of Number types --- tests/integration/transactions/test_sav.py | 4 +- .../core/binarycodec/types/test_number.py | 33 ++- xrpl/core/binarycodec/types/number.py | 202 +++++++++++++++++- 3 files changed, 221 insertions(+), 18 deletions(-) diff --git a/tests/integration/transactions/test_sav.py b/tests/integration/transactions/test_sav.py index 0496e13e7..8d97c18e0 100644 --- a/tests/integration/transactions/test_sav.py +++ b/tests/integration/transactions/test_sav.py @@ -61,9 +61,7 @@ async def test_sav_lifecycle(self, client): tx = VaultCreate( account=vault_owner.address, asset=IssuedCurrency(currency="USD", issuer=issuer_wallet.address), - # TODO: This throws a Number::normalize 1 exception in rippled, why ?? - # Possible errors in serialization of Number type - # asset_maximum="1000", + asset_maximum="1000", ) response = await sign_and_reliable_submission_async(tx, vault_owner, client) self.assertEqual(response.status, ResponseStatus.SUCCESS) diff --git a/tests/unit/core/binarycodec/types/test_number.py b/tests/unit/core/binarycodec/types/test_number.py index fb9898308..6eb930a11 100644 --- a/tests/unit/core/binarycodec/types/test_number.py +++ b/tests/unit/core/binarycodec/types/test_number.py @@ -5,11 +5,32 @@ class TestNumber(unittest.TestCase): def test_serialization_and_deserialization(self): - number_bytes = Number.from_value(124) - self.assertEqual((number_bytes).to_json(), "124.0") + serialized_number = Number.from_value("124") + self.assertEqual(serialized_number.to_json(), "1240000000000000e-13") - number_bytes = Number.from_value(0) - self.assertEqual((number_bytes).to_json(), "0.0") + serialized_number = Number.from_value("1000") + self.assertEqual(serialized_number.to_json(), "1000000000000000e-12") - number_bytes = Number.from_value(-10) - self.assertEqual((number_bytes).to_json(), "-10.0") + serialized_number = Number.from_value("0") + self.assertEqual(serialized_number.to_json(), "0") + + serialized_number = Number.from_value("-1") + self.assertEqual(serialized_number.to_json(), "-1000000000000000e-15") + + serialized_number = Number.from_value("-10") + self.assertEqual(serialized_number.to_json(), "-1000000000000000e-14") + + serialized_number = Number.from_value("123.456") + self.assertEqual(serialized_number.to_json(), "1234560000000000e-13") + + serialized_number = Number.from_value("1.456e-45") + self.assertEqual(serialized_number.to_json(), "1456000000000000e-60") + + serialized_number = Number.from_value("0.456e34") + self.assertEqual(serialized_number.to_json(), "4560000000000000e18") + + serialized_number = Number.from_value("4e34") + self.assertEqual(serialized_number.to_json(), "4000000000000000e19") + + def extreme_limits(self): + pass diff --git a/xrpl/core/binarycodec/types/number.py b/xrpl/core/binarycodec/types/number.py index e7cf324d0..229108764 100644 --- a/xrpl/core/binarycodec/types/number.py +++ b/xrpl/core/binarycodec/types/number.py @@ -1,5 +1,5 @@ -import struct -from typing import Optional, Type +import re +from typing import Optional, Pattern, Tuple, Type from typing_extensions import Self @@ -7,6 +7,175 @@ from xrpl.core.binarycodec.exceptions import XRPLBinaryCodecException from xrpl.core.binarycodec.types.serialized_type import SerializedType +# Note: Much of the ideas and constants in this file are borrowed from the rippled +# implementation of the `Number` and `STNumber` class. Please refer to the cpp code. + +# Limits of representation after normalization of mantissa and exponent +_MIN_MANTISSA = 1000000000000000 +_MAX_MANTISSA = 9999999999999999 + +_MIN_EXPONENT = -32768 +_MAX_EXPONENT = 32768 + + +def normalize(mantissa: int, exponent: int) -> Tuple[int, int]: + """Normalize the mantissa and exponent of a number. + + Args: + mantissa: The mantissa of the input number + exponent: The exponent of the input number + + Returns: + A tuple containing the normalized mantissa and exponent + """ + is_negative = mantissa < 0 + m = abs(mantissa) + + while m < _MIN_MANTISSA and exponent > _MIN_EXPONENT: + exponent -= 1 + m *= 10 + + # Note: This code rounds the normalized mantissa "towards_zero". If your use case + # needs other rounding modes -- to_nearest, up (or) down, let us know with an + # appropriate bug report + while m > _MAX_MANTISSA: + if exponent >= _MAX_EXPONENT: + raise XRPLBinaryCodecException("Mantissa and exponent are too large.") + + exponent += 1 + m //= 10 + + if is_negative: + m = -m + + return (m, exponent) + + +def add32(value: int) -> bytes: + """Add a 32-bit integer to a bytes object. + + Args: + value: The integer to add + + Returns: + A bytes object containing the serialized integer + """ + serialized_bytes = bytes() + serialized_bytes += (value >> 24 & 0xFF).to_bytes(1) + serialized_bytes += (value >> 16 & 0xFF).to_bytes(1) + serialized_bytes += (value >> 8 & 0xFF).to_bytes(1) + serialized_bytes += (value & 0xFF).to_bytes(1) + + return serialized_bytes + + +def add64(value: int) -> bytes: + """Add a 64-bit integer to a bytes object. + + Args: + value: The integer to add + + Returns: + A bytes object containing the serialized integer + """ + serialized_bytes = bytes() + serialized_bytes += (value >> 56 & 0xFF).to_bytes(1) + serialized_bytes += (value >> 48 & 0xFF).to_bytes(1) + serialized_bytes += (value >> 40 & 0xFF).to_bytes(1) + serialized_bytes += (value >> 32 & 0xFF).to_bytes(1) + serialized_bytes += (value >> 24 & 0xFF).to_bytes(1) + serialized_bytes += (value >> 16 & 0xFF).to_bytes(1) + serialized_bytes += (value >> 8 & 0xFF).to_bytes(1) + serialized_bytes += (value & 0xFF).to_bytes(1) + + return serialized_bytes + + +class NumberParts: + """Class representing the parts of a number: mantissa, exponent and sign.""" + + def __init__(self: Self, mantissa: int, exponent: int, is_negative: bool) -> None: + """Initialize a NumberParts instance. + + Args: + mantissa: The mantissa (significant digits) of the number + exponent: The exponent indicating the position of the decimal point + is_negative: Boolean indicating if the number is negative + """ + self.mantissa = mantissa + self.exponent = exponent + self.is_negative = is_negative + + +def extractNumberPartsFromString(value: str) -> NumberParts: + """Extract the mantissa, exponent and sign from a string. + + Args: + value: The string to extract the number parts from + + Returns: + A NumberParts instance containing the mantissa, exponent and sign + """ + VALID_NUMBER_REGEX: Pattern[str] = re.compile( + r"^" # the beginning of the string + + r"([-+]?)" # (optional) + or - character + + r"(0|[1-9][0-9]*)" # mantissa: a number (no leading zeroes, unless 0) + + r"(\.([0-9]+))?" # (optional) decimal point and fractional part + + r"([eE]([+-]?)([0-9]+))?" # (optional) E/e, optional + or -, any number + + r"$" # the end of the string + ) + + matches = re.fullmatch(VALID_NUMBER_REGEX, value) + + if not matches: + raise XRPLBinaryCodecException("Unable to parse number from the input string") + + # Match fields: + # 0 = whole input + # 1 = sign + # 2 = integer portion + # 3 = whole fraction (with '.') + # 4 = fraction (without '.') + # 5 = whole exponent (with 'e') + # 6 = exponent sign + # 7 = exponent number + + is_negative: bool = matches.group(1) == "-" + + # integer only + if matches.group(3) is None and matches.group(5) is None: + mantissa = int(matches.group(2)) + exponent = 0 + + # integer and fraction + if matches.group(3) is not None and matches.group(5) is None: + mantissa = int(matches.group(2) + matches.group(4)) + exponent = -len(matches.group(4)) + + # integer and exponent + if matches.group(3) is None and matches.group(5) is not None: + mantissa = int(matches.group(2)) + exponent = int(matches.group(7)) + + if matches.group(6) == "-": + exponent = -exponent + + # integer, fraction and exponent + if matches.group(3) is not None and matches.group(5) is not None: + mantissa = int(matches.group(2) + matches.group(4)) + implied_exponent = -len(matches.group(4)) + explicit_exponent = int(matches.group(7)) + + if matches.group(6) == "-": + explicit_exponent = -explicit_exponent + + exponent = implied_exponent + explicit_exponent + + if is_negative: + mantissa = -mantissa + + return NumberParts(mantissa, exponent, is_negative) + class Number(SerializedType): """Codec for serializing and deserializing Number fields.""" @@ -23,16 +192,31 @@ def from_parser( # noqa: D102 ) -> Self: # Number type consists of two cpp std::uint_64t (mantissa) and # std::uint_32t (exponent) types which are 8 bytes and 4 bytes respectively + + # Note: Normalization is not required here. It is assumed that the serialized + # format was obtained through correct procedure. return cls(parser.read(12)) @classmethod def from_value(cls: Type[Self], value: str) -> Self: - return cls(struct.pack(">d", float(value))) + number_parts: NumberParts = extractNumberPartsFromString(value) + normalized_mantissa, normalized_exponent = normalize( + number_parts.mantissa, number_parts.exponent + ) + + serialized_mantissa = add64(normalized_mantissa) + serialized_exponent = add32(normalized_exponent) + + assert len(serialized_mantissa) == 8 + assert len(serialized_exponent) == 4 + + return cls(serialized_mantissa + serialized_exponent) def to_json(self: Self) -> str: - unpack_elems = struct.unpack(">d", self.buffer) - if len(unpack_elems) != 1: - raise XRPLBinaryCodecException( - "Deserialization of Number type did not produce exactly one element" - ) - return str(unpack_elems[0]) + mantissa = int.from_bytes(self.buffer[:8], byteorder="big", signed=True) + exponent = int.from_bytes(self.buffer[8:], byteorder="big", signed=True) + + if exponent == 0: + return str(mantissa) + + return f"{mantissa}e{exponent}" From 36469097ca9d2c093160bfd11686c431de697c85 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 4 Mar 2025 16:09:19 -0800 Subject: [PATCH 10/22] docs explanation of transaction models --- xrpl/core/binarycodec/types/number.py | 9 ++++++--- xrpl/models/transactions/vault_clawback.py | 16 +++++++++++++++- xrpl/models/transactions/vault_create.py | 11 ++++++++++- xrpl/models/transactions/vault_delete.py | 3 ++- xrpl/models/transactions/vault_deposit.py | 5 ++++- xrpl/models/transactions/vault_set.py | 11 ++++++++++- xrpl/models/transactions/vault_withdraw.py | 7 ++++++- 7 files changed, 53 insertions(+), 9 deletions(-) diff --git a/xrpl/core/binarycodec/types/number.py b/xrpl/core/binarycodec/types/number.py index 229108764..bda0fcaf9 100644 --- a/xrpl/core/binarycodec/types/number.py +++ b/xrpl/core/binarycodec/types/number.py @@ -1,3 +1,9 @@ +"""Codec for the Number type. + +Note: Much of the ideas and constants in this file are borrowed from the rippled +implementation of the `Number` and `STNumber` class. Please refer to the cpp code. +""" + import re from typing import Optional, Pattern, Tuple, Type @@ -7,9 +13,6 @@ from xrpl.core.binarycodec.exceptions import XRPLBinaryCodecException from xrpl.core.binarycodec.types.serialized_type import SerializedType -# Note: Much of the ideas and constants in this file are borrowed from the rippled -# implementation of the `Number` and `STNumber` class. Please refer to the cpp code. - # Limits of representation after normalization of mantissa and exponent _MIN_MANTISSA = 1000000000000000 _MAX_MANTISSA = 9999999999999999 diff --git a/xrpl/models/transactions/vault_clawback.py b/xrpl/models/transactions/vault_clawback.py index f0660c481..75bf322d7 100644 --- a/xrpl/models/transactions/vault_clawback.py +++ b/xrpl/models/transactions/vault_clawback.py @@ -16,12 +16,26 @@ @dataclass(frozen=True, **KW_ONLY_DATACLASS) class VaultClawback(Transaction): """ - Represents a VaultClawback transaction on the XRP Ledger. + The VaultClawback transaction performs a Clawback from the Vault, exchanging the + shares of an account. Conceptually, the transaction performs VaultWithdraw on + behalf of the Holder, sending the funds to the Issuer account of the asset. + + In case there are insufficient funds for the entire Amount the transaction will + perform a partial Clawback, up to the Vault.AssetAvailable. + + The Clawback transaction must respect any future fees or penalties. """ vault_id: str = REQUIRED # type: ignore + """The ID of the vault from which assets are withdrawn.""" + holder: str = REQUIRED # type: ignore + """The account ID from which to clawback the assets.""" + amount: Optional[Amount] = None + """The asset amount to clawback. When Amount is 0 clawback all funds, up to the + total shares the Holder owns. + """ transaction_type: TransactionType = field( default=TransactionType.VAULT_CLAWBACK, diff --git a/xrpl/models/transactions/vault_create.py b/xrpl/models/transactions/vault_create.py index 3919ac24e..ab7937301 100644 --- a/xrpl/models/transactions/vault_create.py +++ b/xrpl/models/transactions/vault_create.py @@ -31,15 +31,24 @@ class VaultCreateFlagInterface(FlagInterface): @dataclass(frozen=True, **KW_ONLY_DATACLASS) class VaultCreate(Transaction): """ - Represents a VaultCreate transaction on the XRP Ledger. + The VaultCreate transaction creates a new Vault object. """ data: Optional[str] = None + """Arbitrary Vault metadata, limited to 256 bytes.""" + # Keshava: TODO: Include MPT Issue in Asset field asset: Currency = REQUIRED # type: ignore + """The asset (XRP, IOU or MPT) of the Vault.""" + asset_maximum: Optional[str] = None + """The maximum asset amount that can be held in a vault.""" + mptoken_metadata: Optional[str] = None + """Arbitrary metadata about the share MPT, in hex format, limited to 1024 bytes.""" + permissioned_domain_id: Optional[str] = None + """The PermissionedDomain object ID associated with the shares of this Vault.""" transaction_type: TransactionType = field( default=TransactionType.VAULT_CREATE, diff --git a/xrpl/models/transactions/vault_delete.py b/xrpl/models/transactions/vault_delete.py index 4d690badf..9c797c403 100644 --- a/xrpl/models/transactions/vault_delete.py +++ b/xrpl/models/transactions/vault_delete.py @@ -14,10 +14,11 @@ @dataclass(frozen=True, **KW_ONLY_DATACLASS) class VaultDelete(Transaction): """ - Represents a VaultDelete transaction on the XRP Ledger. + The VaultDelete transaction deletes an existing vault object. """ vault_id: str = REQUIRED # type: ignore + """The ID of the vault to be deleted.""" transaction_type: TransactionType = field( default=TransactionType.VAULT_DELETE, diff --git a/xrpl/models/transactions/vault_deposit.py b/xrpl/models/transactions/vault_deposit.py index 6281eef33..ce21c677c 100644 --- a/xrpl/models/transactions/vault_deposit.py +++ b/xrpl/models/transactions/vault_deposit.py @@ -15,11 +15,14 @@ @dataclass(frozen=True, **KW_ONLY_DATACLASS) class VaultDeposit(Transaction): """ - Represents a VaultDeposit transaction on the XRP Ledger. + The VaultDeposit transaction adds Liqudity in exchange for vault shares. """ vault_id: str = REQUIRED # type: ignore + """The ID of the vault to which the assets are deposited.""" + amount: Amount = REQUIRED # type: ignore + """Asset amount to deposit.""" transaction_type: TransactionType = field( default=TransactionType.VAULT_DEPOSIT, diff --git a/xrpl/models/transactions/vault_set.py b/xrpl/models/transactions/vault_set.py index 03eab3162..0ec215b66 100644 --- a/xrpl/models/transactions/vault_set.py +++ b/xrpl/models/transactions/vault_set.py @@ -15,13 +15,22 @@ @dataclass(frozen=True, **KW_ONLY_DATACLASS) class VaultSet(Transaction): """ - Represents a VaultSet transaction on the XRP Ledger. + The VaultSet updates an existing Vault ledger object. """ vault_id: str = REQUIRED # type: ignore + """The ID of the Vault to be modified. Must be included when updating the Vault.""" + domain_id: Optional[str] = None + """The PermissionedDomain object ID associated with the shares of this Vault.""" + data: Optional[str] = None + """Arbitrary Vault metadata, limited to 256 bytes.""" + asset_maximum: Optional[str] = None + """The maximum asset amount that can be held in a vault. The value cannot be lower + than the current AssetTotal unless the value is 0. + """ transaction_type: TransactionType = field( default=TransactionType.VAULT_SET, diff --git a/xrpl/models/transactions/vault_withdraw.py b/xrpl/models/transactions/vault_withdraw.py index db45acdc8..39992d435 100644 --- a/xrpl/models/transactions/vault_withdraw.py +++ b/xrpl/models/transactions/vault_withdraw.py @@ -16,12 +16,17 @@ @dataclass(frozen=True, **KW_ONLY_DATACLASS) class VaultWithdraw(Transaction): """ - Represents a VaultWithdraw transaction on the XRP Ledger. + The VaultWithdraw transaction withdraws assets in exchange for the vault's shares. """ vault_id: str = REQUIRED # type: ignore + """The ID of the vault from which assets are withdrawn.""" + amount: Amount = REQUIRED # type: ignore + """The exact amount of Vault asset to withdraw.""" + destination: Optional[str] = None + """An account to receive the assets. It must be able to receive the asset.""" transaction_type: TransactionType = field( default=TransactionType.VAULT_WITHDRAW, From 307c515d310032ffee7523f01ecc839d009088ca Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 5 Mar 2025 10:32:57 -0800 Subject: [PATCH 11/22] handle 0 case in Number type --- .../core/binarycodec/types/test_number.py | 8 +++++- xrpl/core/binarycodec/types/number.py | 28 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/tests/unit/core/binarycodec/types/test_number.py b/tests/unit/core/binarycodec/types/test_number.py index 6eb930a11..7e9a0304c 100644 --- a/tests/unit/core/binarycodec/types/test_number.py +++ b/tests/unit/core/binarycodec/types/test_number.py @@ -33,4 +33,10 @@ def test_serialization_and_deserialization(self): self.assertEqual(serialized_number.to_json(), "4000000000000000e19") def extreme_limits(self): - pass + lowest_mantissa = "-9223372036854776" + serialized_number = Number.from_value(lowest_mantissa + "e3") + self.assertEqual(serialized_number.hex(), "FFDF3B645A1CAC0800000003") + + highest_mantissa = "9223372036854776" + serialized_number = Number.from_value(highest_mantissa + "e3") + self.assertEqual(serialized_number.hex(), "0020C49BA5E353F700000003") diff --git a/xrpl/core/binarycodec/types/number.py b/xrpl/core/binarycodec/types/number.py index bda0fcaf9..34224668b 100644 --- a/xrpl/core/binarycodec/types/number.py +++ b/xrpl/core/binarycodec/types/number.py @@ -187,6 +187,12 @@ def __init__(self: Self, buffer: bytes) -> None: """Construct a Number from given bytes.""" super().__init__(buffer) + def display_serialized_hex(self: Self) -> str: + """Display the serialized hex representation of the number. Utility function + for debugging. + """ + return self.buffer.hex().upper() + @classmethod def from_parser( # noqa: D102 cls: Type[Self], @@ -203,9 +209,21 @@ def from_parser( # noqa: D102 @classmethod def from_value(cls: Type[Self], value: str) -> Self: number_parts: NumberParts = extractNumberPartsFromString(value) - normalized_mantissa, normalized_exponent = normalize( - number_parts.mantissa, number_parts.exponent - ) + + # `0` value is represented as a mantissa of 0 and an exponent of -2147483648 + # This is an artifact of the rippled implementation. To ensure compatibility of + # the codec, we mirror this behavior. + if ( + number_parts.mantissa == 0 + and number_parts.exponent == 0 + and not number_parts.is_negative + ): + normalized_mantissa = 0 + normalized_exponent = -2147483648 + else: + normalized_mantissa, normalized_exponent = normalize( + number_parts.mantissa, number_parts.exponent + ) serialized_mantissa = add64(normalized_mantissa) serialized_exponent = add32(normalized_exponent) @@ -222,4 +240,8 @@ def to_json(self: Self) -> str: if exponent == 0: return str(mantissa) + # `0` value is represented as a mantissa of 0 and an exponent of -2147483648 + if mantissa == 0 and exponent == -2147483648: + return "0" + return f"{mantissa}e{exponent}" From e97a4b70c90522fdf382eb6682232b68c7ad2412 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 5 Mar 2025 10:43:31 -0800 Subject: [PATCH 12/22] simplify the extractNumberParts logic; Avoid using magic numbers; --- xrpl/core/binarycodec/types/number.py | 49 +++++++++++---------------- 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/xrpl/core/binarycodec/types/number.py b/xrpl/core/binarycodec/types/number.py index 34224668b..d2d3e8bf9 100644 --- a/xrpl/core/binarycodec/types/number.py +++ b/xrpl/core/binarycodec/types/number.py @@ -20,6 +20,8 @@ _MIN_EXPONENT = -32768 _MAX_EXPONENT = 32768 +_DEFAULT_VALUE_EXPONENT = -2147483648 + def normalize(mantissa: int, exponent: int) -> Tuple[int, int]: """Normalize the mantissa and exponent of a number. @@ -146,33 +148,20 @@ def extractNumberPartsFromString(value: str) -> NumberParts: is_negative: bool = matches.group(1) == "-" # integer only - if matches.group(3) is None and matches.group(5) is None: + if matches.group(3) is None: mantissa = int(matches.group(2)) exponent = 0 - - # integer and fraction - if matches.group(3) is not None and matches.group(5) is None: + else: + # handle the fraction input mantissa = int(matches.group(2) + matches.group(4)) exponent = -len(matches.group(4)) - # integer and exponent - if matches.group(3) is None and matches.group(5) is not None: - mantissa = int(matches.group(2)) - exponent = int(matches.group(7)) - - if matches.group(6) == "-": - exponent = -exponent - - # integer, fraction and exponent - if matches.group(3) is not None and matches.group(5) is not None: - mantissa = int(matches.group(2) + matches.group(4)) - implied_exponent = -len(matches.group(4)) - explicit_exponent = int(matches.group(7)) - + # exponent is specified in the input + if matches.group(5) is not None: if matches.group(6) == "-": - explicit_exponent = -explicit_exponent - - exponent = implied_exponent + explicit_exponent + exponent -= int(matches.group(7)) + else: + exponent += int(matches.group(7)) if is_negative: mantissa = -mantissa @@ -199,9 +188,6 @@ def from_parser( # noqa: D102 parser: BinaryParser, length_hint: Optional[int] = None, # noqa: ANN401 ) -> Self: - # Number type consists of two cpp std::uint_64t (mantissa) and - # std::uint_32t (exponent) types which are 8 bytes and 4 bytes respectively - # Note: Normalization is not required here. It is assumed that the serialized # format was obtained through correct procedure. return cls(parser.read(12)) @@ -210,16 +196,16 @@ def from_parser( # noqa: D102 def from_value(cls: Type[Self], value: str) -> Self: number_parts: NumberParts = extractNumberPartsFromString(value) - # `0` value is represented as a mantissa of 0 and an exponent of -2147483648 - # This is an artifact of the rippled implementation. To ensure compatibility of - # the codec, we mirror this behavior. + # `0` value is represented as a mantissa with 0 and an exponent of + # _DEFAULT_VALUE_EXPONENT. This is an artifact of the rippled implementation. + # To ensure compatibility of the codec, we mirror this behavior. if ( number_parts.mantissa == 0 and number_parts.exponent == 0 and not number_parts.is_negative ): normalized_mantissa = 0 - normalized_exponent = -2147483648 + normalized_exponent = _DEFAULT_VALUE_EXPONENT else: normalized_mantissa, normalized_exponent = normalize( number_parts.mantissa, number_parts.exponent @@ -228,6 +214,8 @@ def from_value(cls: Type[Self], value: str) -> Self: serialized_mantissa = add64(normalized_mantissa) serialized_exponent = add32(normalized_exponent) + # Number type consists of two cpp std::uint_64t (mantissa) and + # std::uint_32t (exponent) types which are 8 bytes and 4 bytes respectively assert len(serialized_mantissa) == 8 assert len(serialized_exponent) == 4 @@ -240,8 +228,9 @@ def to_json(self: Self) -> str: if exponent == 0: return str(mantissa) - # `0` value is represented as a mantissa of 0 and an exponent of -2147483648 - if mantissa == 0 and exponent == -2147483648: + # `0` value is represented as a mantissa with 0 and an + # exponent of _DEFAULT_VALUE_EXPONENT + if mantissa == 0 and exponent == _DEFAULT_VALUE_EXPONENT: return "0" return f"{mantissa}e{exponent}" From 1617cad46ad7672007f33560c612383fb8da7142 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 5 Mar 2025 13:05:51 -0800 Subject: [PATCH 13/22] introduce model for MPTIssue --- xrpl/models/amounts/mpt_amount.py | 13 +++++++++++++ xrpl/models/transactions/vault_create.py | 7 +++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/xrpl/models/amounts/mpt_amount.py b/xrpl/models/amounts/mpt_amount.py index ca6f48f21..e0bb11a01 100644 --- a/xrpl/models/amounts/mpt_amount.py +++ b/xrpl/models/amounts/mpt_amount.py @@ -39,3 +39,16 @@ def to_dict(self: Self) -> Dict[str, str]: The dictionary representation of an MPTAmount. """ return {**super().to_dict(), "value": str(self.value)} + + +class MPTIssue: + """ + This class represents an MPT issue. It is similar to the Issue class, but + it is used with MPT amounts. + """ + + mpt_id: str = REQUIRED # type: ignore + """ + MPTID is a 192-bit concatenation of a 32-bit account sequence and a 160-bit + account id. + """ diff --git a/xrpl/models/transactions/vault_create.py b/xrpl/models/transactions/vault_create.py index ab7937301..ee7b2f6a4 100644 --- a/xrpl/models/transactions/vault_create.py +++ b/xrpl/models/transactions/vault_create.py @@ -4,9 +4,9 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Optional +from typing import Optional, Union -from xrpl.models.amounts import Amount +from xrpl.models.amounts.mpt_amount import MPTIssue from xrpl.models.currencies import Currency from xrpl.models.flags import FlagInterface from xrpl.models.required import REQUIRED @@ -37,8 +37,7 @@ class VaultCreate(Transaction): data: Optional[str] = None """Arbitrary Vault metadata, limited to 256 bytes.""" - # Keshava: TODO: Include MPT Issue in Asset field - asset: Currency = REQUIRED # type: ignore + asset: Union[Currency, MPTIssue] = REQUIRED # type: ignore """The asset (XRP, IOU or MPT) of the Vault.""" asset_maximum: Optional[str] = None From 671690a78c22247e0b4d7f959530c6d7a32fd17c Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 5 Mar 2025 16:06:11 -0800 Subject: [PATCH 14/22] Update VaultCreate model to include Withdrawal Policy --- tests/integration/transactions/test_sav.py | 1 + xrpl/core/binarycodec/definitions/definitions.json | 10 ++++++++++ xrpl/models/transactions/vault_create.py | 12 +++++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/integration/transactions/test_sav.py b/tests/integration/transactions/test_sav.py index 8d97c18e0..10c13f52c 100644 --- a/tests/integration/transactions/test_sav.py +++ b/tests/integration/transactions/test_sav.py @@ -62,6 +62,7 @@ async def test_sav_lifecycle(self, client): account=vault_owner.address, asset=IssuedCurrency(currency="USD", issuer=issuer_wallet.address), asset_maximum="1000", + withdrawal_policy=1, ) response = await sign_and_reliable_submission_async(tx, vault_owner, client) self.assertEqual(response.status, ResponseStatus.SUCCESS) diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index 5bdb4f60b..c51164ae0 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -2699,6 +2699,16 @@ "type": "UInt8" } ], + [ + "WithdrawalPolicy", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 20, + "type": "UInt8" + } + ], [ "TakerPaysCurrency", { diff --git a/xrpl/models/transactions/vault_create.py b/xrpl/models/transactions/vault_create.py index ee7b2f6a4..a7e08248f 100644 --- a/xrpl/models/transactions/vault_create.py +++ b/xrpl/models/transactions/vault_create.py @@ -46,9 +46,19 @@ class VaultCreate(Transaction): mptoken_metadata: Optional[str] = None """Arbitrary metadata about the share MPT, in hex format, limited to 1024 bytes.""" - permissioned_domain_id: Optional[str] = None + domain_id: Optional[str] = None """The PermissionedDomain object ID associated with the shares of this Vault.""" + withdrawal_policy: Optional[int] = None + """Indicates the withdrawal strategy used by the Vault. + + The below withdrawal policy is supported: + + Strategy Name Value Description + strFirstComeFirstServe 1 Requests are processed on a first-come-first- + serve basis. + """ + transaction_type: TransactionType = field( default=TransactionType.VAULT_CREATE, init=False, From b09b37a1623113f46768b651e2fb20f14c741210 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 5 Mar 2025 16:08:01 -0800 Subject: [PATCH 15/22] remove irrelevant tests for amount binary codec --- .../unit/core/binarycodec/types/test_amount.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/unit/core/binarycodec/types/test_amount.py b/tests/unit/core/binarycodec/types/test_amount.py index c0fa4f2d1..00246d377 100644 --- a/tests/unit/core/binarycodec/types/test_amount.py +++ b/tests/unit/core/binarycodec/types/test_amount.py @@ -108,23 +108,6 @@ def test_assert_xrp_is_valid_passes(self): amount.verify_xrp_value(valid_zero) amount.verify_xrp_value(valid_amount) - # Note: these values are obtained from the following rippled unit test: - # https://github.com/XRPLF/rippled/blob/33e1c42599857336d792effc753795911bdb13f0/src/test/protocol/STAmount_test.cpp#L513 - # However, the limiting values allowed by the STAmount type are much higher and - # smaller. - def test_large_small_values(self): - small_value = "5499999999999999e-95" - - serialized_representation = amount.Amount.from_value(small_value) - self.assertEqual(amount.Amount.to_json(serialized_representation), small_value) - - large_value = "15499999999999999e79" - serialized_representation = amount.Amount.from_value(large_value) - self.assertEqual(amount.Amount.to_json(serialized_representation), large_value) - - amount.verify_xrp_value(small_value) - amount.verify_xrp_value(large_value) - def test_assert_xrp_is_valid_raises(self): invalid_amount_large = "1e20" invalid_amount_small = "1e-7" From 537cc2f40a66b1c1874c53346127d487a4f3d99b Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 6 Mar 2025 10:30:24 -0800 Subject: [PATCH 16/22] reorder the REQUIRED fields to be at the top of the transactions model; Update the changelog file --- CHANGELOG.md | 1 + xrpl/models/transactions/vault_create.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a784b1c12..18596cfbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Improved validation for models to also check param types +- Support for Single Asset Vault (XLS-65d) ## [4.1.0] - 2025-2-13 diff --git a/xrpl/models/transactions/vault_create.py b/xrpl/models/transactions/vault_create.py index a7e08248f..4252378b4 100644 --- a/xrpl/models/transactions/vault_create.py +++ b/xrpl/models/transactions/vault_create.py @@ -34,12 +34,12 @@ class VaultCreate(Transaction): The VaultCreate transaction creates a new Vault object. """ - data: Optional[str] = None - """Arbitrary Vault metadata, limited to 256 bytes.""" - asset: Union[Currency, MPTIssue] = REQUIRED # type: ignore """The asset (XRP, IOU or MPT) of the Vault.""" + data: Optional[str] = None + """Arbitrary Vault metadata, limited to 256 bytes.""" + asset_maximum: Optional[str] = None """The maximum asset amount that can be held in a vault.""" From 6c4f8011823241dd80daee755e62ecdc1a76f3a0 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 6 Mar 2025 12:55:00 -0800 Subject: [PATCH 17/22] pretty print of Number values --- .../core/binarycodec/types/test_number.py | 10 ++--- xrpl/core/binarycodec/types/number.py | 41 ++++++++++++++++++- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/tests/unit/core/binarycodec/types/test_number.py b/tests/unit/core/binarycodec/types/test_number.py index 7e9a0304c..86160edad 100644 --- a/tests/unit/core/binarycodec/types/test_number.py +++ b/tests/unit/core/binarycodec/types/test_number.py @@ -6,22 +6,22 @@ class TestNumber(unittest.TestCase): def test_serialization_and_deserialization(self): serialized_number = Number.from_value("124") - self.assertEqual(serialized_number.to_json(), "1240000000000000e-13") + self.assertEqual(serialized_number.to_json(), "124") serialized_number = Number.from_value("1000") - self.assertEqual(serialized_number.to_json(), "1000000000000000e-12") + self.assertEqual(serialized_number.to_json(), "1000") serialized_number = Number.from_value("0") self.assertEqual(serialized_number.to_json(), "0") serialized_number = Number.from_value("-1") - self.assertEqual(serialized_number.to_json(), "-1000000000000000e-15") + self.assertEqual(serialized_number.to_json(), "-1") serialized_number = Number.from_value("-10") - self.assertEqual(serialized_number.to_json(), "-1000000000000000e-14") + self.assertEqual(serialized_number.to_json(), "-10") serialized_number = Number.from_value("123.456") - self.assertEqual(serialized_number.to_json(), "1234560000000000e-13") + self.assertEqual(serialized_number.to_json(), "123.456") serialized_number = Number.from_value("1.456e-45") self.assertEqual(serialized_number.to_json(), "1456000000000000e-60") diff --git a/xrpl/core/binarycodec/types/number.py b/xrpl/core/binarycodec/types/number.py index d2d3e8bf9..0f9c82a16 100644 --- a/xrpl/core/binarycodec/types/number.py +++ b/xrpl/core/binarycodec/types/number.py @@ -222,6 +222,15 @@ def from_value(cls: Type[Self], value: str) -> Self: return cls(serialized_mantissa + serialized_exponent) def to_json(self: Self) -> str: + """Convert the Number to a JSON string. + + Note: This method is faithful to rippled's `Number::to_string()` method. + This ensures API compatibility between rippled and xrpl-py regarding the JSON + representation of Number objects. + + Returns: + A JSON string representing the Number + """ mantissa = int.from_bytes(self.buffer[:8], byteorder="big", signed=True) exponent = int.from_bytes(self.buffer[8:], byteorder="big", signed=True) @@ -233,4 +242,34 @@ def to_json(self: Self) -> str: if mantissa == 0 and exponent == _DEFAULT_VALUE_EXPONENT: return "0" - return f"{mantissa}e{exponent}" + # Use scientific notation for very small or large numbers + if exponent < -25 or exponent > -5: + return f"{mantissa}e{exponent}" + + is_negative = mantissa < 0 + mantissa = abs(mantissa) + + # The below padding values are influenced by the exponent range of [-25, -5] + # in the above if-condition. Values outside of this range use the scientific + # notation and do not go through the below logic. + PAD_PREFIX = 27 + PAD_SUFFIX = 23 + + raw_value: str = "0" * PAD_PREFIX + str(mantissa) + "0" * PAD_SUFFIX + + # Note: The rationale for choosing 43 is that the highest mantissa has 16 + # digits in decimal representation and the PAD_PREFIX has 27 characters. + # 27 + 16 sums upto 43 characters. + OFFSET = exponent + 43 + assert OFFSET > 0, "Exponent is below acceptable limit" + + generate_mantissa: str = raw_value[:OFFSET].lstrip("0") + + if generate_mantissa == "": + generate_mantissa = "0" + + generate_exponent: str = raw_value[OFFSET:].rstrip("0") + if generate_exponent != "": + generate_exponent = "." + generate_exponent + + return f"{'-' if is_negative else ''}{generate_mantissa}{generate_exponent}" From 27659370ac8aab26bc54c21d38bf0c95a5af7249 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 6 Mar 2025 14:20:58 -0800 Subject: [PATCH 18/22] update the data member name of the MPTIssue class --- xrpl/models/amounts/mpt_amount.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xrpl/models/amounts/mpt_amount.py b/xrpl/models/amounts/mpt_amount.py index e0bb11a01..5810a3558 100644 --- a/xrpl/models/amounts/mpt_amount.py +++ b/xrpl/models/amounts/mpt_amount.py @@ -47,8 +47,8 @@ class MPTIssue: it is used with MPT amounts. """ - mpt_id: str = REQUIRED # type: ignore + mpt_issuance_id: str = REQUIRED # type: ignore """ - MPTID is a 192-bit concatenation of a 32-bit account sequence and a 160-bit - account id. + mpt_issuance_id is a 192-bit concatenation of a 32-bit account sequence and a + 160-bit account id. """ From 18bfd1c8651505c8ffbd97488f33c58e7cab79e0 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 6 Mar 2025 14:29:01 -0800 Subject: [PATCH 19/22] pacify linter errors --- xrpl/core/binarycodec/types/number.py | 8 ++++ xrpl/models/amounts/mpt_amount.py | 2 +- xrpl/models/transactions/vault_clawback.py | 6 +-- xrpl/models/transactions/vault_create.py | 50 +++++++++++++++++----- xrpl/models/transactions/vault_delete.py | 8 +--- xrpl/models/transactions/vault_deposit.py | 8 +--- xrpl/models/transactions/vault_set.py | 10 ++--- xrpl/models/transactions/vault_withdraw.py | 8 ++-- 8 files changed, 60 insertions(+), 40 deletions(-) diff --git a/xrpl/core/binarycodec/types/number.py b/xrpl/core/binarycodec/types/number.py index 0f9c82a16..212c26634 100644 --- a/xrpl/core/binarycodec/types/number.py +++ b/xrpl/core/binarycodec/types/number.py @@ -194,6 +194,14 @@ def from_parser( # noqa: D102 @classmethod def from_value(cls: Type[Self], value: str) -> Self: + """Construct a Number from a string. + + Args: + value: The string to construct the Number from + + Returns: + A Number instance + """ number_parts: NumberParts = extractNumberPartsFromString(value) # `0` value is represented as a mantissa with 0 and an exponent of diff --git a/xrpl/models/amounts/mpt_amount.py b/xrpl/models/amounts/mpt_amount.py index 5810a3558..b6b912bbb 100644 --- a/xrpl/models/amounts/mpt_amount.py +++ b/xrpl/models/amounts/mpt_amount.py @@ -49,6 +49,6 @@ class MPTIssue: mpt_issuance_id: str = REQUIRED # type: ignore """ - mpt_issuance_id is a 192-bit concatenation of a 32-bit account sequence and a + mpt_issuance_id is a 192-bit concatenation of a 32-bit account sequence and a 160-bit account id. """ diff --git a/xrpl/models/transactions/vault_clawback.py b/xrpl/models/transactions/vault_clawback.py index 75bf322d7..5f2576f8a 100644 --- a/xrpl/models/transactions/vault_clawback.py +++ b/xrpl/models/transactions/vault_clawback.py @@ -1,6 +1,4 @@ -""" -Represents a VaultClawback transaction on the XRP Ledger. -""" +"""Represents a VaultClawback transaction on the XRP Ledger.""" from dataclasses import dataclass, field from typing import Optional @@ -33,7 +31,7 @@ class VaultClawback(Transaction): """The account ID from which to clawback the assets.""" amount: Optional[Amount] = None - """The asset amount to clawback. When Amount is 0 clawback all funds, up to the + """The asset amount to clawback. When Amount is 0 clawback all funds, up to the total shares the Holder owns. """ diff --git a/xrpl/models/transactions/vault_create.py b/xrpl/models/transactions/vault_create.py index 4252378b4..50a6db03c 100644 --- a/xrpl/models/transactions/vault_create.py +++ b/xrpl/models/transactions/vault_create.py @@ -1,6 +1,4 @@ -""" -Represents a VaultCreate transaction on the XRP Ledger. -""" +"""Represents a VaultCreate transaction on the XRP Ledger.""" from dataclasses import dataclass, field from enum import Enum @@ -16,23 +14,54 @@ class VaultCreateFlag(int, Enum): + """Flags for the VaultCreate transaction.""" - TF_VAULT_PRIVATE = 0x0001 - TF_VAULT_SHARE_NON_TRANSFERABLE = 0x0002 + TF_FREEZE = 0x0001 + """ + Indicates that the vault should be frozen. + """ + TF_UNFREEZE = 0x0002 + """ + Indicates that the vault should be unfrozen. + """ + + TF_VAULT_PRIVATE = 0x0003 + """ + Indicates that the vault is private. It can only be set during Vault creation. + """ + TF_VAULT_SHARE_NON_TRANSFERABLE = 0x0004 + """ + Indicates the vault share is non-transferable. It can only be set during Vault + creation. + """ class VaultCreateFlagInterface(FlagInterface): + """Interface for the VaultCreate transaction flags.""" + TF_FREEZE: bool + """ + Indicates that the vault should be frozen. + """ + TF_UNFREEZE: bool + """ + Indicates that the vault should be unfrozen. + """ TF_VAULT_PRIVATE: bool + """ + Indicates that the vault is private. It can only be set during Vault creation. + """ TF_VAULT_SHARE_NON_TRANSFERABLE: bool + """ + Indicates the vault share is non-transferable. It can only be set during Vault + creation. + """ @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) class VaultCreate(Transaction): - """ - The VaultCreate transaction creates a new Vault object. - """ + """The VaultCreate transaction creates a new Vault object.""" asset: Union[Currency, MPTIssue] = REQUIRED # type: ignore """The asset (XRP, IOU or MPT) of the Vault.""" @@ -50,9 +79,8 @@ class VaultCreate(Transaction): """The PermissionedDomain object ID associated with the shares of this Vault.""" withdrawal_policy: Optional[int] = None - """Indicates the withdrawal strategy used by the Vault. - - The below withdrawal policy is supported: + """Indicates the withdrawal strategy used by the Vault. The below withdrawal policy + is supported: Strategy Name Value Description strFirstComeFirstServe 1 Requests are processed on a first-come-first- diff --git a/xrpl/models/transactions/vault_delete.py b/xrpl/models/transactions/vault_delete.py index 9c797c403..c36376b5e 100644 --- a/xrpl/models/transactions/vault_delete.py +++ b/xrpl/models/transactions/vault_delete.py @@ -1,6 +1,4 @@ -""" -Represents a VaultDelete transaction on the XRP Ledger. -""" +"""Represents a VaultDelete transaction on the XRP Ledger.""" from dataclasses import dataclass, field @@ -13,9 +11,7 @@ @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) class VaultDelete(Transaction): - """ - The VaultDelete transaction deletes an existing vault object. - """ + """The VaultDelete transaction deletes an existing vault object.""" vault_id: str = REQUIRED # type: ignore """The ID of the vault to be deleted.""" diff --git a/xrpl/models/transactions/vault_deposit.py b/xrpl/models/transactions/vault_deposit.py index ce21c677c..4ea87d48e 100644 --- a/xrpl/models/transactions/vault_deposit.py +++ b/xrpl/models/transactions/vault_deposit.py @@ -1,6 +1,4 @@ -""" -Represents a VaultDeposit transaction on the XRP Ledger. -""" +"""Represents a VaultDeposit transaction on the XRP Ledger.""" from dataclasses import dataclass, field @@ -14,9 +12,7 @@ @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) class VaultDeposit(Transaction): - """ - The VaultDeposit transaction adds Liqudity in exchange for vault shares. - """ + """The VaultDeposit transaction adds Liqudity in exchange for vault shares.""" vault_id: str = REQUIRED # type: ignore """The ID of the vault to which the assets are deposited.""" diff --git a/xrpl/models/transactions/vault_set.py b/xrpl/models/transactions/vault_set.py index 0ec215b66..9ccce20a6 100644 --- a/xrpl/models/transactions/vault_set.py +++ b/xrpl/models/transactions/vault_set.py @@ -1,6 +1,4 @@ -""" -Represents a VaultSet transaction on the XRP Ledger. -""" +"""Represents a VaultSet transaction on the XRP Ledger.""" from dataclasses import dataclass, field from typing import Optional @@ -14,9 +12,7 @@ @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) class VaultSet(Transaction): - """ - The VaultSet updates an existing Vault ledger object. - """ + """The VaultSet updates an existing Vault ledger object.""" vault_id: str = REQUIRED # type: ignore """The ID of the Vault to be modified. Must be included when updating the Vault.""" @@ -28,7 +24,7 @@ class VaultSet(Transaction): """Arbitrary Vault metadata, limited to 256 bytes.""" asset_maximum: Optional[str] = None - """The maximum asset amount that can be held in a vault. The value cannot be lower + """The maximum asset amount that can be held in a vault. The value cannot be lower than the current AssetTotal unless the value is 0. """ diff --git a/xrpl/models/transactions/vault_withdraw.py b/xrpl/models/transactions/vault_withdraw.py index 39992d435..0e4ddb7cc 100644 --- a/xrpl/models/transactions/vault_withdraw.py +++ b/xrpl/models/transactions/vault_withdraw.py @@ -1,6 +1,4 @@ -""" -Represents a VaultWithdraw transaction on the XRP Ledger. -""" +"""Represents a VaultWithdraw transaction on the XRP Ledger.""" from dataclasses import dataclass, field from typing import Optional @@ -15,8 +13,8 @@ @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) class VaultWithdraw(Transaction): - """ - The VaultWithdraw transaction withdraws assets in exchange for the vault's shares. + """The VaultWithdraw transaction withdraws assets in exchange for the vault's + shares. """ vault_id: str = REQUIRED # type: ignore From f967f2366914368f20a668dfd858eb12be264f48 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S <21219765+ckeshava@users.noreply.github.com> Date: Thu, 6 Mar 2025 14:47:30 -0800 Subject: [PATCH 20/22] Update tests/unit/core/binarycodec/types/test_number.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- tests/unit/core/binarycodec/types/test_number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/core/binarycodec/types/test_number.py b/tests/unit/core/binarycodec/types/test_number.py index 86160edad..a98a38e74 100644 --- a/tests/unit/core/binarycodec/types/test_number.py +++ b/tests/unit/core/binarycodec/types/test_number.py @@ -32,7 +32,7 @@ def test_serialization_and_deserialization(self): serialized_number = Number.from_value("4e34") self.assertEqual(serialized_number.to_json(), "4000000000000000e19") - def extreme_limits(self): + def test_extreme_limits(self): lowest_mantissa = "-9223372036854776" serialized_number = Number.from_value(lowest_mantissa + "e3") self.assertEqual(serialized_number.hex(), "FFDF3B645A1CAC0800000003") From eb785533a1ea1e5c0f8ca0ca7b709c63e34bb6c3 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 6 Mar 2025 16:03:25 -0800 Subject: [PATCH 21/22] Accept mypy suggestions --- xrpl/core/binarycodec/types/number.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/xrpl/core/binarycodec/types/number.py b/xrpl/core/binarycodec/types/number.py index 212c26634..80d6e2dc9 100644 --- a/xrpl/core/binarycodec/types/number.py +++ b/xrpl/core/binarycodec/types/number.py @@ -66,10 +66,10 @@ def add32(value: int) -> bytes: A bytes object containing the serialized integer """ serialized_bytes = bytes() - serialized_bytes += (value >> 24 & 0xFF).to_bytes(1) - serialized_bytes += (value >> 16 & 0xFF).to_bytes(1) - serialized_bytes += (value >> 8 & 0xFF).to_bytes(1) - serialized_bytes += (value & 0xFF).to_bytes(1) + serialized_bytes += (value >> 24 & 0xFF).to_bytes(1, "big") + serialized_bytes += (value >> 16 & 0xFF).to_bytes(1, "big") + serialized_bytes += (value >> 8 & 0xFF).to_bytes(1, "big") + serialized_bytes += (value & 0xFF).to_bytes(1, "big") return serialized_bytes @@ -84,14 +84,14 @@ def add64(value: int) -> bytes: A bytes object containing the serialized integer """ serialized_bytes = bytes() - serialized_bytes += (value >> 56 & 0xFF).to_bytes(1) - serialized_bytes += (value >> 48 & 0xFF).to_bytes(1) - serialized_bytes += (value >> 40 & 0xFF).to_bytes(1) - serialized_bytes += (value >> 32 & 0xFF).to_bytes(1) - serialized_bytes += (value >> 24 & 0xFF).to_bytes(1) - serialized_bytes += (value >> 16 & 0xFF).to_bytes(1) - serialized_bytes += (value >> 8 & 0xFF).to_bytes(1) - serialized_bytes += (value & 0xFF).to_bytes(1) + serialized_bytes += (value >> 56 & 0xFF).to_bytes(1, "big") + serialized_bytes += (value >> 48 & 0xFF).to_bytes(1, "big") + serialized_bytes += (value >> 40 & 0xFF).to_bytes(1, "big") + serialized_bytes += (value >> 32 & 0xFF).to_bytes(1, "big") + serialized_bytes += (value >> 24 & 0xFF).to_bytes(1, "big") + serialized_bytes += (value >> 16 & 0xFF).to_bytes(1, "big") + serialized_bytes += (value >> 8 & 0xFF).to_bytes(1, "big") + serialized_bytes += (value & 0xFF).to_bytes(1, "big") return serialized_bytes From 4cb1d0226599d104bb38b0b358c16c3c4244aabd Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 6 Mar 2025 16:09:52 -0800 Subject: [PATCH 22/22] use custom method of Number class for debugging unit tests --- tests/unit/core/binarycodec/types/test_number.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/unit/core/binarycodec/types/test_number.py b/tests/unit/core/binarycodec/types/test_number.py index a98a38e74..830af2b29 100644 --- a/tests/unit/core/binarycodec/types/test_number.py +++ b/tests/unit/core/binarycodec/types/test_number.py @@ -35,8 +35,12 @@ def test_serialization_and_deserialization(self): def test_extreme_limits(self): lowest_mantissa = "-9223372036854776" serialized_number = Number.from_value(lowest_mantissa + "e3") - self.assertEqual(serialized_number.hex(), "FFDF3B645A1CAC0800000003") + self.assertEqual( + serialized_number.display_serialized_hex(), "FFDF3B645A1CAC0800000003" + ) highest_mantissa = "9223372036854776" serialized_number = Number.from_value(highest_mantissa + "e3") - self.assertEqual(serialized_number.hex(), "0020C49BA5E353F700000003") + self.assertEqual( + serialized_number.display_serialized_hex(), "0020C49BA5E353F800000003" + )