diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index 818d6d800..6a0dc81c6 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -197,8 +197,12 @@ fixReducedOffersV2 DeepFreeze DynamicNFT PermissionedDomains + +# 2.5.0 Amendments +SingleAssetVault fixFrozenLPTokenTransfer fixInvalidTxFlags + # 2.5.0 Amendments PermissionDelegation Batch diff --git a/CHANGELOG.md b/CHANGELOG.md index 94698538d..ac2588e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,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) - Support for `Account Permission` and `Account Permission Delegation` (XLS-74d, XLS-75d) - Support for the `Batch` amendment (XLS-56d) diff --git a/tests/integration/reqs/test_vault_info.py b/tests/integration/reqs/test_vault_info.py new file mode 100644 index 000000000..915e0677b --- /dev/null +++ b/tests/integration/reqs/test_vault_info.py @@ -0,0 +1,59 @@ +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 xrpl.models.currencies import XRP +from xrpl.models.requests import AccountObjects, VaultInfo +from xrpl.models.response import ResponseStatus +from xrpl.models.transactions import VaultCreate +from xrpl.models.transactions.vault_create import VaultCreateFlag +from xrpl.wallet import Wallet + + +class TestVaultInfo(IntegrationTestCase): + @test_async_and_sync(globals()) + async def test_basic_functionality(self, client): + + VAULT_OWNER = Wallet.create() + await fund_wallet_async(VAULT_OWNER) + + # Create a vault + # Additionally validate the usage of flags in the VaultCreate transaction + tx = VaultCreate( + account=VAULT_OWNER.address, + asset=XRP(), + withdrawal_policy=1, + flags=VaultCreateFlag.TF_VAULT_PRIVATE + | VaultCreateFlag.TF_VAULT_SHARE_NON_TRANSFERABLE, + ) + response = await sign_and_reliable_submission_async(tx, VAULT_OWNER, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Note: Due to the setup of the integration testing framework, it is difficult + # to obtain the next-sequence number of an account (in sync Client tests). + # Hence, AccountObjects request is used to fetch the `index` of the vault + # ledger-object. + vault_object = await client.request( + AccountObjects( + account=VAULT_OWNER.address, + type="vault", + ) + ) + self.assertEqual(len(vault_object.result["account_objects"]), 1) + self.assertEqual( + vault_object.result["account_objects"][0]["LedgerEntryType"], "Vault" + ) + vault_object_hash = vault_object.result["account_objects"][0]["index"] + + # Fetch information about the vault using VaultInfo request + response = await client.request( + VaultInfo( + vault_id=vault_object_hash, + ) + ) + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["vault"]["Owner"], VAULT_OWNER.address) + self.assertEqual(response.result["vault"]["index"], vault_object_hash) diff --git a/tests/integration/transactions/test_single_asset_vault.py b/tests/integration/transactions/test_single_asset_vault.py new file mode 100644 index 000000000..afdb4ea68 --- /dev/null +++ b/tests/integration/transactions/test_single_asset_vault.py @@ -0,0 +1,167 @@ +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 ( + AccountSet, + AccountSetAsfFlag, + Payment, + TrustSet, + VaultClawback, + VaultCreate, + VaultDelete, + VaultDeposit, + VaultSet, + VaultWithdraw, +) +from xrpl.models.amounts.issued_currency_amount import IssuedCurrencyAmount +from xrpl.models.currencies import IssuedCurrency +from xrpl.models.requests import AccountObjects, LedgerEntry +from xrpl.models.requests.account_objects import AccountObjectType +from xrpl.models.response import ResponseStatus +from xrpl.models.transactions.vault_create import WithdrawalPolicy +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): + + vault_owner = Wallet.create() + await fund_wallet_async(vault_owner) + + issuer_wallet = Wallet.create() + await fund_wallet_async(issuer_wallet) + + # Set up the relevant flags on the issuer_wallet account -- This is + # a pre-requisite for a Vault to hold the Issued Currency Asset + # This test uses an IOU to demonstrate the VaultClawback functionality. + # Clawback is not possible with the native XRP asset. + response = await sign_and_reliable_submission_async( + AccountSet( + account=issuer_wallet.classic_address, + set_flag=AccountSetAsfFlag.ASF_DEFAULT_RIPPLE, + ), + issuer_wallet, + ) + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + response = await sign_and_reliable_submission_async( + AccountSet( + account=issuer_wallet.classic_address, + set_flag=AccountSetAsfFlag.ASF_ALLOW_TRUSTLINE_CLAWBACK, + ), + issuer_wallet, + ) + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # 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.a: Create a vault + tx = VaultCreate( + account=vault_owner.address, + asset=IssuedCurrency(currency="USD", issuer=issuer_wallet.address), + assets_maximum="1000", + withdrawal_policy=WithdrawalPolicy.VAULT_STRATEGY_FIRST_COME_FIRST_SERVE, + ) + 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.b: 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) + + VAULT_ID = account_objects_response.result["account_objects"][0]["index"] + + # Step-1.c: Verify the existence of the vault with ledger_entry RPC call + ledger_entry_response = await client.request(LedgerEntry(index=VAULT_ID)) + self.assertEqual(ledger_entry_response.status, ResponseStatus.SUCCESS) + + # Step-2: Update the characteristics of the vault with VaultSet transaction + # print(await client.request(AccountInfo(account=vault_owner.address))) + 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=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") + + # Step-4: Execute a VaultWithdraw transaction + tx = VaultWithdraw( + account=WALLET.address, + vault_id=VAULT_ID, + 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") + + # 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") + + # Step-6: Delete the Vault with VaultDelete transaction + tx = VaultDelete( + account=vault_owner.address, + vault_id=VAULT_ID, + ) + 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/fixtures/data/codec-fixtures.json b/tests/unit/core/binarycodec/fixtures/data/codec-fixtures.json index 5ae4f2c7c..1a5b866dd 100644 --- a/tests/unit/core/binarycodec/fixtures/data/codec-fixtures.json +++ b/tests/unit/core/binarycodec/fixtures/data/codec-fixtures.json @@ -4884,6 +4884,17 @@ "TransactionType": "DelegateSet", "TxnSignature": "D05A89D0B489DEC1CECBE0D33BA656C929CDCCC75D4D41B282B378544975B87A70C3E42147D980D1F6E2E4DC6316C99D7E2D4F6335F147C71C0DAA0D6516150D" } + }, + { + "binary": "12004173008114204288D2E47F8EF6C99BCC457966320D124097119300038D7EA4C68000FFFFFFF40010140103180000000000000000000000005553440000000000204288D2E47F8EF6C99BCC457966320D12409711", + "json": { + "Account": "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW", + "TransactionType": "VaultCreate", + "SigningPubKey": "", + "Asset": {"currency": "USD", "issuer": "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"}, + "AssetsMaximum": "1000", + "WithdrawalPolicy": 1 + } } ], "ledgerData": [{ 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..830af2b29 --- /dev/null +++ b/tests/unit/core/binarycodec/types/test_number.py @@ -0,0 +1,46 @@ +import unittest + +from xrpl.core.binarycodec.types.number import Number + + +class TestNumber(unittest.TestCase): + def test_serialization_and_deserialization(self): + serialized_number = Number.from_value("124") + self.assertEqual(serialized_number.to_json(), "124") + + serialized_number = Number.from_value("1000") + 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(), "-1") + + serialized_number = Number.from_value("-10") + self.assertEqual(serialized_number.to_json(), "-10") + + serialized_number = Number.from_value("123.456") + self.assertEqual(serialized_number.to_json(), "123.456") + + 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 test_extreme_limits(self): + lowest_mantissa = "-9223372036854776" + serialized_number = Number.from_value(lowest_mantissa + "e3") + self.assertEqual( + serialized_number.display_serialized_hex(), "FFDF3B645A1CAC0800000003" + ) + + highest_mantissa = "9223372036854776" + serialized_number = Number.from_value(highest_mantissa + "e3") + self.assertEqual( + serialized_number.display_serialized_hex(), "0020C49BA5E353F800000003" + ) diff --git a/tests/unit/models/requests/test_vault_info.py b/tests/unit/models/requests/test_vault_info.py new file mode 100644 index 000000000..efa9c2625 --- /dev/null +++ b/tests/unit/models/requests/test_vault_info.py @@ -0,0 +1,32 @@ +from unittest import TestCase + +from xrpl.models.requests import VaultInfo + +VAULT_ID = "CE47F59928D43773A8A9CB7F525BE031977EFB72A23FF094C1C326E687D2B567" + + +class TestVaultInfo(TestCase): + def test_valid_vault_info(self): + request = VaultInfo( + vault_id=VAULT_ID, + ) + self.assertTrue(request.is_valid()) + + def test_vault_info_requires_parameters(self): + with self.assertRaises(ValueError): + VaultInfo() + + def test_vault_info_rejects_conflicting_parameters(self): + with self.assertRaises(ValueError): + VaultInfo( + vault_id=VAULT_ID, + owner="rN6zcSynkRnf8zcgTVrRL8K7r4ovE7J4Zj", + seq=1234567890, + ) + + def test_valid_vault_info_with_owner_and_seq(self): + request = VaultInfo( + owner="rN6zcSynkRnf8zcgTVrRL8K7r4ovE7J4Zj", + seq=1234567890, + ) + self.assertTrue(request.is_valid()) diff --git a/tests/unit/models/transactions/test_vault_create.py b/tests/unit/models/transactions/test_vault_create.py new file mode 100644 index 000000000..d3a67497d --- /dev/null +++ b/tests/unit/models/transactions/test_vault_create.py @@ -0,0 +1,81 @@ +from unittest import TestCase + +from xrpl.models.currencies import IssuedCurrency +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions.vault_create import VaultCreate +from xrpl.utils import str_to_hex + +_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" + + +class TestVaultCreate(TestCase): + def test_valid(self): + tx = VaultCreate( + account=_ACCOUNT, + asset=IssuedCurrency(currency="USD", issuer=_ACCOUNT), + assets_maximum="1000", + withdrawal_policy=1, + data=str_to_hex("A" * 256), + ) + self.assertTrue(tx.is_valid()) + + def test_long_data_field(self): + with self.assertRaises(XRPLModelException) as e: + VaultCreate( + account=_ACCOUNT, + asset=IssuedCurrency(currency="USD", issuer=_ACCOUNT), + assets_maximum="1000", + withdrawal_policy=1, + data=str_to_hex("A" * 257), + ) + self.assertEqual( + e.exception.args[0], + str( + { + "data": "Data must be less than 256 bytes " + "(alternatively, 512 hex characters)." + } + ), + ) + + def test_long_mpt_metadata_field(self): + with self.assertRaises(XRPLModelException) as e: + VaultCreate( + account=_ACCOUNT, + asset=IssuedCurrency(currency="USD", issuer=_ACCOUNT), + assets_maximum="1000", + withdrawal_policy=1, + # Note: MPTMetadata is associated with a Multi-Purpose token and not a + # conventional IOU token. This unit test demonstrates the validity of + # the transaction model only. It must not be misconstrued as an + # archetype of a VaultCreate transaction. + mptoken_metadata=str_to_hex("A" * 1025), + ) + self.assertEqual( + e.exception.args[0], + str( + { + "mptoken_metadata": "Metadata must be less than 1024 bytes " + "(alternatively, 2048 hex characters)." + } + ), + ) + + def test_invalid_domain_id_field(self): + with self.assertRaises(XRPLModelException) as e: + VaultCreate( + account=_ACCOUNT, + asset=IssuedCurrency(currency="USD", issuer=_ACCOUNT), + assets_maximum="1000", + withdrawal_policy=1, + domain_id="A" * 14, + ) + self.assertEqual( + e.exception.args[0], + str( + { + "domain_id": "Invalid domain ID: Length must be 32 characters " + "(64 hex characters)." + } + ), + ) diff --git a/tests/unit/models/transactions/test_vault_delete.py b/tests/unit/models/transactions/test_vault_delete.py new file mode 100644 index 000000000..e9ce1c939 --- /dev/null +++ b/tests/unit/models/transactions/test_vault_delete.py @@ -0,0 +1,32 @@ +from unittest import TestCase + +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions.vault_delete import VaultDelete + +_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" +_VAULT_ID = "B982D2AAEF6014E6BE3194D939865453D56D16FF7081BB1D0ED865C708ABCEEE" + + +class TestVaultDelete(TestCase): + def test_valid(self): + tx = VaultDelete( + account=_ACCOUNT, + vault_id=_VAULT_ID, + ) + self.assertTrue(tx.is_valid()) + + def test_invalid_vault_id_field(self): + with self.assertRaises(XRPLModelException) as e: + VaultDelete( + account=_ACCOUNT, + vault_id="0", + ) + self.assertEqual( + e.exception.args[0], + str( + { + "vault_id": "Invalid vault ID: Length must be 32 characters " + "(64 hex characters)." + } + ), + ) diff --git a/tests/unit/models/transactions/test_vault_set.py b/tests/unit/models/transactions/test_vault_set.py new file mode 100644 index 000000000..e562d99ca --- /dev/null +++ b/tests/unit/models/transactions/test_vault_set.py @@ -0,0 +1,69 @@ +from unittest import TestCase + +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions.vault_set import VaultSet +from xrpl.utils import str_to_hex + +_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" +_VAULT_ID = "DB303FC1C7611B22C09E773B51044F6BEA02EF917DF59A2E2860871E167066A5" + + +class TestVaultSet(TestCase): + def test_valid(self): + tx = VaultSet( + account=_ACCOUNT, + vault_id=_VAULT_ID, + assets_maximum="1000", + data=str_to_hex("A" * 256), + ) + self.assertTrue(tx.is_valid()) + + def test_invalid_vault_id_field_length(self): + with self.assertRaises(XRPLModelException) as e: + VaultSet( + account=_ACCOUNT, + vault_id=_VAULT_ID[:-1], + ) + self.assertEqual( + e.exception.args[0], + str( + { + "vault_id": "Invalid vault ID: Length must be 32 characters " + "(64 hex characters)." + } + ), + ) + + def test_long_data_field(self): + with self.assertRaises(XRPLModelException) as e: + VaultSet( + account=_ACCOUNT, + vault_id=_VAULT_ID, + data=str_to_hex("A" * 257), + ) + self.assertEqual( + e.exception.args[0], + str( + { + "data": "Data must be less than 256 bytes " + "(alternatively, 512 hex characters)." + } + ), + ) + + def test_invalid_domain_id_field(self): + with self.assertRaises(XRPLModelException) as e: + VaultSet( + account=_ACCOUNT, + vault_id=_VAULT_ID, + domain_id="A" * 14, + ) + self.assertEqual( + e.exception.args[0], + str( + { + "domain_id": "Invalid domain ID: Length must be 32 characters " + "(64 hex characters)." + } + ), + ) diff --git a/tests/unit/models/transactions/test_vault_withdraw.py b/tests/unit/models/transactions/test_vault_withdraw.py new file mode 100644 index 000000000..abe719f89 --- /dev/null +++ b/tests/unit/models/transactions/test_vault_withdraw.py @@ -0,0 +1,37 @@ +from unittest import TestCase + +from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions.vault_withdraw import VaultWithdraw + +_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" +_VAULT_ID = "B982D2AAEF6014E6BE3194D939865453D56D16FF7081BB1D0ED865C708ABCEEE" + + +class TestVaultWithdraw(TestCase): + def test_valid(self): + tx = VaultWithdraw( + account=_ACCOUNT, + vault_id=_VAULT_ID, + amount=IssuedCurrencyAmount(currency="USD", issuer=_ACCOUNT, value="100"), + ) + self.assertTrue(tx.is_valid()) + + def test_invalid_vault_id_field(self): + with self.assertRaises(XRPLModelException) as e: + VaultWithdraw( + account=_ACCOUNT, + amount=IssuedCurrencyAmount( + currency="USD", issuer=_ACCOUNT, value="100" + ), + vault_id="0", + ) + self.assertEqual( + e.exception.args[0], + str( + { + "vault_id": "Invalid vault ID: Length must be 32 characters " + "(64 hex characters)." + } + ), + ) diff --git a/xrpl/asyncio/transaction/main.py b/xrpl/asyncio/transaction/main.py index 4e2dc5346..4ec1dda28 100644 --- a/xrpl/asyncio/transaction/main.py +++ b/xrpl/asyncio/transaction/main.py @@ -504,6 +504,7 @@ async def _calculate_fee_per_transaction_type( elif 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/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index 4899a4df0..9fdd5ff6a 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -3276,6 +3276,7 @@ "terLAST": -91, "terNO_ACCOUNT": -96, "terNO_AMM": -87, + "tedADDRESS_COLLISION": -86, "terNO_AUTH": -95, "terNO_LINE": -94, "terNO_RIPPLE": -90, @@ -3341,10 +3342,10 @@ "TicketCreate": 10, "TrustSet": 20, "UNLModify": 102, - "VaultClawback": 70, "VaultCreate": 65, - "VaultDelete": 67, + "VaultClawback": 70, "VaultDeposit": 68, + "VaultDelete": 67, "VaultSet": 66, "VaultWithdraw": 69, "XChainAccountCreateCommit": 44, 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 new file mode 100644 index 000000000..1244b0c85 --- /dev/null +++ b/xrpl/core/binarycodec/types/number.py @@ -0,0 +1,285 @@ +"""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 + +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 + +# Limits of representation after normalization of mantissa and exponent +_MIN_MANTISSA = 1000000000000000 +_MAX_MANTISSA = 9999999999999999 + +_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. + + 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, "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 + + +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, "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 + + +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( + f"Unable to parse number from the input string: {value}" + ) + + # 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: + mantissa = int(matches.group(2)) + exponent = 0 + else: + # handle the fraction input + mantissa = int(matches.group(2) + matches.group(4)) + exponent = -len(matches.group(4)) + + # exponent is specified in the input + if matches.group(5) is not None: + if matches.group(6) == "-": + exponent -= int(matches.group(7)) + else: + exponent += int(matches.group(7)) + + if is_negative: + mantissa = -mantissa + + return NumberParts(mantissa, exponent, is_negative) + + +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) + + 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], + parser: BinaryParser, + length_hint: Optional[int] = None, # noqa: ANN401 + ) -> Self: + # 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: + """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 + # _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 = _DEFAULT_VALUE_EXPONENT + else: + normalized_mantissa, normalized_exponent = normalize( + number_parts.mantissa, number_parts.exponent + ) + + 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 + + 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:12], byteorder="big", signed=True) + + if exponent == 0: + return str(mantissa) + + # `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" + + # 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}" diff --git a/xrpl/models/amounts/__init__.py b/xrpl/models/amounts/__init__.py index 4b9e2df0d..6a2fc8489 100644 --- a/xrpl/models/amounts/__init__.py +++ b/xrpl/models/amounts/__init__.py @@ -11,6 +11,7 @@ is_mpt, is_xrp, ) +from xrpl.models.amounts.clawback_amount import ClawbackAmount from xrpl.models.amounts.issued_currency_amount import IssuedCurrencyAmount from xrpl.models.amounts.mpt_amount import MPTAmount @@ -22,4 +23,5 @@ "is_mpt", "get_amount_value", "MPTAmount", + "ClawbackAmount", ] diff --git a/xrpl/models/amounts/clawback_amount.py b/xrpl/models/amounts/clawback_amount.py new file mode 100644 index 000000000..0353aded0 --- /dev/null +++ b/xrpl/models/amounts/clawback_amount.py @@ -0,0 +1,12 @@ +""" +A ClawbackAmount is an object specifying a currency, a quantity of that currency, and +the counterparty (issuer) on the trustline that holds the value. Clawback is possible +only for IOU Tokens and MPT Tokens. XRP Amounts cannot be clawed back. +""" + +from typing import Union + +from xrpl.models.amounts.issued_currency_amount import IssuedCurrencyAmount +from xrpl.models.amounts.mpt_amount import MPTAmount + +ClawbackAmount = Union[IssuedCurrencyAmount, MPTAmount] diff --git a/xrpl/models/requests/__init__.py b/xrpl/models/requests/__init__.py index 73c53b591..f9bc577ad 100644 --- a/xrpl/models/requests/__init__.py +++ b/xrpl/models/requests/__init__.py @@ -51,6 +51,7 @@ from xrpl.models.requests.transaction_entry import TransactionEntry from xrpl.models.requests.tx import Tx from xrpl.models.requests.unsubscribe import Unsubscribe +from xrpl.models.requests.vault_info import VaultInfo __all__ = [ "AccountChannels", @@ -110,4 +111,5 @@ "TransactionEntry", "Tx", "Unsubscribe", + "VaultInfo", ] diff --git a/xrpl/models/requests/account_objects.py b/xrpl/models/requests/account_objects.py index 60fbfaf38..740e09eb6 100644 --- a/xrpl/models/requests/account_objects.py +++ b/xrpl/models/requests/account_objects.py @@ -37,6 +37,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/requests/ledger_entry.py b/xrpl/models/requests/ledger_entry.py index 56cc3ed54..15347cfed 100644 --- a/xrpl/models/requests/ledger_entry.py +++ b/xrpl/models/requests/ledger_entry.py @@ -42,6 +42,7 @@ class LedgerEntryType(str, Enum): PAYMENT_CHANNEL = "payment_channel" PERMISSIONED_DOMAIN = "permissioned_domain" SIGNER_LIST = "signer_list" + SINGLE_ASSET_VAULT = "vault" STATE = "state" TICKET = "ticket" MPT_ISSUANCE = "mpt_issuance" @@ -281,6 +282,28 @@ class Ticket(BaseModel): """ +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class Vault(BaseModel): + """Required fields for requesting a Vault ledger-object if not querying by + object ID. + """ + + owner: str = REQUIRED # type: ignore + """ + This field is required. + + :meta hide-value: + """ + + seq: int = REQUIRED # type: ignore + """ + This field is required. + + :meta hide-value: + """ + + @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) class XChainClaimID(XChainBridge): @@ -340,6 +363,7 @@ class LedgerEntry(Request, LookupByLedgerRequest): payment_channel: Optional[str] = None permissioned_domain: Optional[Union[str, PermissionedDomain]] = None ripple_state: Optional[RippleState] = None + vault: Optional[Union[str, Vault]] = None ticket: Optional[Union[str, Ticket]] = None bridge_account: Optional[str] = None bridge: Optional[XChainBridge] = None @@ -375,6 +399,7 @@ def _get_errors(self: Self) -> Dict[str, str]: self.payment_channel, self.permissioned_domain, self.ripple_state, + self.vault, self.ticket, self.xchain_claim_id, self.xchain_create_account_claim_id, diff --git a/xrpl/models/requests/request.py b/xrpl/models/requests/request.py index ee5d9cb1e..285208856 100644 --- a/xrpl/models/requests/request.py +++ b/xrpl/models/requests/request.py @@ -90,6 +90,9 @@ class RequestMethod(str, Enum): # price oracle methods GET_AGGREGATE_PRICE = "get_aggregate_price" + # vault methods + VAULT_INFO = "vault_info" + # generic unknown/unsupported request # (there is no XRPL analog, this model is specific to xrpl-py) GENERIC_REQUEST = "zzgeneric_request" diff --git a/xrpl/models/requests/vault_info.py b/xrpl/models/requests/vault_info.py new file mode 100644 index 000000000..6e2755b93 --- /dev/null +++ b/xrpl/models/requests/vault_info.py @@ -0,0 +1,50 @@ +""" +This request retrieves information about a Single Asset Vault. + +All information retrieved is relative to a particular version of the ledger. +""" + +from dataclasses import dataclass, field +from typing import Optional + +from typing_extensions import Self + +from xrpl.models.requests.request import LookupByLedgerRequest, Request, RequestMethod +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class VaultInfo(Request, LookupByLedgerRequest): + """ + This request retrieves information about a Single Asset Vault. + + All information retrieved is relative to a particular version of the ledger. + + Information about a vault ledger-object can be fetched by providing either the + vault_id or both owner and seq values. Please check the documentation for more + details. + """ + + vault_id: Optional[str] = None + """The object ID of the Vault to be returned.""" + + owner: Optional[str] = None + """The account address of the Vault Owner.""" + + seq: Optional[int] = None + """The transaction sequence number that created the vault.""" + + method: RequestMethod = field(default=RequestMethod.VAULT_INFO, init=False) + + def __post_init__(self: Self) -> None: + """Validate that either vault_id or both owner and seq are provided.""" + if self.vault_id is None and (self.owner is None or self.seq is None): + raise ValueError( + "Either vault_id must be provided, or both owner and seq must be " + "provided" + ) + if self.vault_id is not None and ( + self.owner is not None or self.seq is not None + ): + raise ValueError("Cannot provide both vault_id and owner/seq parameters") diff --git a/xrpl/models/transactions/__init__.py b/xrpl/models/transactions/__init__.py index d1d2b6c62..112a0ca75 100644 --- a/xrpl/models/transactions/__init__.py +++ b/xrpl/models/transactions/__init__.py @@ -104,6 +104,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, ) @@ -208,6 +214,12 @@ "TrustSet", "TrustSetFlag", "TrustSetFlagInterface", + "VaultClawback", + "VaultCreate", + "VaultDelete", + "VaultDeposit", + "VaultSet", + "VaultWithdraw", "XChainAccountCreateCommit", "XChainAddAccountCreateAttestation", "XChainAddClaimAttestation", diff --git a/xrpl/models/transactions/clawback.py b/xrpl/models/transactions/clawback.py index 8174c624d..3ac272ee4 100644 --- a/xrpl/models/transactions/clawback.py +++ b/xrpl/models/transactions/clawback.py @@ -3,16 +3,11 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Dict, Optional, Union +from typing import Dict, Optional from typing_extensions import Self -from xrpl.models.amounts import ( - IssuedCurrencyAmount, - MPTAmount, - is_issued_currency, - is_xrp, -) +from xrpl.models.amounts import ClawbackAmount, is_issued_currency, is_xrp from xrpl.models.amounts.amount import is_mpt from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import Transaction @@ -25,7 +20,7 @@ class Clawback(Transaction): """The clawback transaction claws back issued funds from token holders.""" - amount: Union[IssuedCurrencyAmount, MPTAmount] = REQUIRED # type: ignore + amount: ClawbackAmount = REQUIRED # type: ignore """ The amount of currency to claw back. The issuer field is used for the token holder's address, from whom the tokens will be clawed back. diff --git a/xrpl/models/transactions/types/transaction_type.py b/xrpl/models/transactions/types/transaction_type.py index a344fc455..5389c90e3 100644 --- a/xrpl/models/transactions/types/transaction_type.py +++ b/xrpl/models/transactions/types/transaction_type.py @@ -54,6 +54,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..7def3df61 --- /dev/null +++ b/xrpl/models/transactions/vault_clawback.py @@ -0,0 +1,41 @@ +"""Represents a VaultClawback transaction on the XRP Ledger.""" + +from dataclasses import dataclass, field +from typing import Optional + +from xrpl.models.amounts import ClawbackAmount +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): + """ + 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[ClawbackAmount] = 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, + init=False, + ) diff --git a/xrpl/models/transactions/vault_create.py b/xrpl/models/transactions/vault_create.py new file mode 100644 index 000000000..01dc92ec3 --- /dev/null +++ b/xrpl/models/transactions/vault_create.py @@ -0,0 +1,112 @@ +"""Represents a VaultCreate transaction on the XRP Ledger.""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Optional, Union + +from typing_extensions import Self + +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 +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + +VAULT_MAX_DATA_LENGTH = 256 * 2 +VAULT_MAX_DOMAIN_ID_LENGTH = 1024 * 2 +_VAULT_MAX_MPTOKEN_METADATA_LENGTH = 32 * 2 + + +class VaultCreateFlag(int, Enum): + """Flags for the VaultCreate transaction.""" + + TF_VAULT_PRIVATE = 0x00010000 + """ + Indicates that the vault is private. It can only be set during Vault creation. + """ + TF_VAULT_SHARE_NON_TRANSFERABLE = 0x00020000 + """ + 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_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. + """ + + +class WithdrawalPolicy(int, Enum): + """Withdrawal policy for the Vault.""" + + VAULT_STRATEGY_FIRST_COME_FIRST_SERVE = 1 + """Requests are processed on a first-come-first-serve basis.""" + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class VaultCreate(Transaction): + """The VaultCreate transaction creates a new Vault object.""" + + asset: Currency = REQUIRED # type: ignore + """The asset (XRP, IOU or MPT) of the Vault.""" + + data: Optional[str] = None + """Arbitrary Vault metadata, limited to 256 bytes.""" + + assets_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.""" + + domain_id: Optional[str] = None + """The PermissionedDomain object ID associated with the shares of this Vault.""" + + withdrawal_policy: Optional[Union[int, WithdrawalPolicy]] = None + """Indicates the withdrawal strategy used by the Vault. The below withdrawal policy + is supported: + + Strategy Name Value Description + vaultStrategyFirstComeFirstServe 1 Requests are processed on a first- + come-first-serve basis. + """ + + transaction_type: TransactionType = field( + default=TransactionType.VAULT_CREATE, + init=False, + ) + + def _get_errors(self: Self) -> Dict[str, str]: + errors = super()._get_errors() + + if self.data is not None and len(self.data) > VAULT_MAX_DATA_LENGTH: + errors["data"] = ( + "Data must be less than 256 bytes (alternatively, 512 hex characters)." + ) + if self.mptoken_metadata is not None and len(self.mptoken_metadata) > ( + _VAULT_MAX_MPTOKEN_METADATA_LENGTH + ): + errors["mptoken_metadata"] = ( + "Metadata must be less than 1024 bytes " + "(alternatively, 2048 hex characters)." + ) + if ( + self.domain_id is not None + and len(self.domain_id) != VAULT_MAX_DOMAIN_ID_LENGTH + ): + errors["domain_id"] = ( + "Invalid domain ID: Length must be 32 characters (64 hex characters)." + ) + + return errors diff --git a/xrpl/models/transactions/vault_delete.py b/xrpl/models/transactions/vault_delete.py new file mode 100644 index 000000000..9b9a36947 --- /dev/null +++ b/xrpl/models/transactions/vault_delete.py @@ -0,0 +1,37 @@ +"""Represents a VaultDelete transaction on the XRP Ledger.""" + +from dataclasses import dataclass, field +from typing import Dict + +from typing_extensions import Self + +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 + +_MAX_VAULT_ID_LENGTH = 64 + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class VaultDelete(Transaction): + """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, + init=False, + ) + + def _get_errors(self: Self) -> Dict[str, str]: + errors = super()._get_errors() + + if len(self.vault_id) != _MAX_VAULT_ID_LENGTH: + errors["vault_id"] = ( + "Invalid vault ID: Length must be 32 characters (64 hex characters)." + ) + + return errors diff --git a/xrpl/models/transactions/vault_deposit.py b/xrpl/models/transactions/vault_deposit.py new file mode 100644 index 000000000..4ea87d48e --- /dev/null +++ b/xrpl/models/transactions/vault_deposit.py @@ -0,0 +1,26 @@ +"""Represents a VaultDeposit transaction on the XRP Ledger.""" + +from dataclasses import dataclass, field + +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 +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): + """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, + init=False, + ) diff --git a/xrpl/models/transactions/vault_set.py b/xrpl/models/transactions/vault_set.py new file mode 100644 index 000000000..a6ac715b2 --- /dev/null +++ b/xrpl/models/transactions/vault_set.py @@ -0,0 +1,63 @@ +"""Represents a VaultSet transaction on the XRP Ledger.""" + +from dataclasses import dataclass, field +from typing import Dict, Optional + +from typing_extensions import Self + +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.transactions.vault_create import ( + VAULT_MAX_DATA_LENGTH, + VAULT_MAX_DOMAIN_ID_LENGTH, +) +from xrpl.models.transactions.vault_delete import _MAX_VAULT_ID_LENGTH +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): + """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.""" + + assets_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, + init=False, + ) + + def _get_errors(self: Self) -> Dict[str, str]: + errors = super()._get_errors() + + if self.data is not None and len(self.data) > VAULT_MAX_DATA_LENGTH: + errors["data"] = ( + "Data must be less than 256 bytes (alternatively, 512 hex characters)." + ) + if ( + self.domain_id is not None + and len(self.domain_id) != VAULT_MAX_DOMAIN_ID_LENGTH + ): + errors["domain_id"] = ( + "Invalid domain ID: Length must be 32 characters (64 hex characters)." + ) + + if len(self.vault_id) != _MAX_VAULT_ID_LENGTH: + errors["vault_id"] = ( + "Invalid vault ID: Length must be 32 characters (64 hex characters)." + ) + + return errors diff --git a/xrpl/models/transactions/vault_withdraw.py b/xrpl/models/transactions/vault_withdraw.py new file mode 100644 index 000000000..ea4bd7b57 --- /dev/null +++ b/xrpl/models/transactions/vault_withdraw.py @@ -0,0 +1,45 @@ +"""Represents a VaultWithdraw transaction on the XRP Ledger.""" + +from dataclasses import dataclass, field +from typing import Dict, Optional + +from typing_extensions import Self + +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 +from xrpl.models.transactions.vault_delete import _MAX_VAULT_ID_LENGTH +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): + """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, + init=False, + ) + + def _get_errors(self: Self) -> Dict[str, str]: + errors = super()._get_errors() + + if len(self.vault_id) != _MAX_VAULT_ID_LENGTH: + errors["vault_id"] = ( + "Invalid vault ID: Length must be 32 characters (64 hex characters)." + ) + + return errors