diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index c266a72fa..81426cbe3 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -196,6 +196,7 @@ fixEnforceNFTokenTrustline fixReducedOffersV2 DeepFreeze PermissionedDomains +PermissionDelegation # This section can be used to simulate various FeeSettings scenarios for rippled node in standalone mode [voting] diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e483e47..2450cc3a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,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 `Account Permission` and `Account Permission Delegation` (XLS-74d, XLS-75d) ## [4.1.0] - 2025-2-13 diff --git a/tests/integration/transactions/test_delegate_set.py b/tests/integration/transactions/test_delegate_set.py new file mode 100644 index 000000000..048ef5a5e --- /dev/null +++ b/tests/integration/transactions/test_delegate_set.py @@ -0,0 +1,162 @@ +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.requests import AccountObjects, AccountObjectType, LedgerEntry +from xrpl.models.requests.ledger_entry import Delegate +from xrpl.models.response import ResponseStatus +from xrpl.models.transactions import ( + AccountSet, + DelegateSet, + GranularPermission, + Payment, +) +from xrpl.models.transactions.delegate_set import Permission +from xrpl.models.transactions.types import TransactionType +from xrpl.utils import xrp_to_drops +from xrpl.wallet.main import Wallet + + +class TestDelegateSet(IntegrationTestCase): + @test_async_and_sync(globals()) + async def test_delegation_with_no_permission(self, client): + # Note: Using WALLET, DESTINATION accounts could pollute the test results + alice = Wallet.create() + await fund_wallet_async(alice) + bob = Wallet.create() + await fund_wallet_async(bob) + carol = Wallet.create() + await fund_wallet_async(carol) + + # Use bob's account to execute a transaction on behalf of alice + payment = Payment( + account=alice.address, + amount=xrp_to_drops(1), + destination=carol.address, + delegate=bob.address, + ) + response = await sign_and_reliable_submission_async( + payment, bob, client, check_fee=False + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + + # The lack of AccountPermissionSet transaction will result in a tecNO_PERMISSION + self.assertEqual(response.result["engine_result"], "tecNO_PERMISSION") + + @test_async_and_sync(globals()) + async def test_delegate_set_workflow(self, client): + # Note: Using WALLET, DESTINATION accounts could pollute the test results + alice = Wallet.create() + await fund_wallet_async(alice) + bob = Wallet.create() + await fund_wallet_async(bob) + carol = Wallet.create() + await fund_wallet_async(carol) + + delegate_set = DelegateSet( + account=alice.address, + authorize=bob.address, + # Authorize bob account to execute Payment transactions and + # modify the domain of an account behalf of alice's account. + permissions=[ + Permission(permission_value=TransactionType.PAYMENT), + Permission(permission_value=GranularPermission.ACCOUNT_DOMAIN_SET), + ], + ) + response = await sign_and_reliable_submission_async( + delegate_set, alice, client, check_fee=False + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Use the bob's account to execute a transaction on behalf of alice + payment = Payment( + account=alice.address, + amount=xrp_to_drops(1), + destination=carol.address, + delegate=bob.address, + ) + response = await sign_and_reliable_submission_async( + payment, bob, client, check_fee=False + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Validate that the transaction was signed by bob + self.assertEqual(response.result["tx_json"]["Account"], alice.address) + self.assertEqual(response.result["tx_json"]["Delegate"], bob.address) + self.assertEqual(response.result["tx_json"]["SigningPubKey"], bob.public_key) + + # Use the bob's account to execute a transaction on behalf of alice + account_set = AccountSet( + account=alice.address, + delegate=bob.address, + email_hash="10000000002000000000300000000012", + ) + response = await sign_and_reliable_submission_async( + account_set, bob, client, check_fee=False + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tecNO_PERMISSION") + + # test ledger entry + ledger_entry_response = await client.request( + LedgerEntry( + delegate=Delegate( + account=alice.address, + authorize=bob.address, + ), + ) + ) + self.assertTrue(ledger_entry_response.is_successful()) + self.assertEqual( + ledger_entry_response.result["node"]["LedgerEntryType"], + "Delegate", + ) + self.assertEqual(ledger_entry_response.result["node"]["Account"], alice.address) + self.assertEqual(ledger_entry_response.result["node"]["Authorize"], bob.address) + self.assertEqual(len(ledger_entry_response.result["node"]["Permissions"]), 2) + + @test_async_and_sync(globals()) + async def test_fetch_delegate_account_objects(self, client): + # Note: Using WALLET, DESTINATION accounts could pollute the test results + alice = Wallet.create() + await fund_wallet_async(alice) + bob = Wallet.create() + await fund_wallet_async(bob) + + delegate_set = DelegateSet( + account=alice.address, + authorize=bob.address, + # Authorize bob's account to execute Payment transactions + # and authorize a trustline on behalf of alice's account. + permissions=[ + Permission(permission_value=TransactionType.PAYMENT), + Permission(permission_value=GranularPermission.TRUSTLINE_AUTHORIZE), + ], + ) + response = await sign_and_reliable_submission_async( + delegate_set, alice, client, check_fee=False + ) + + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + account_objects_response = await client.request( + AccountObjects(account=alice.address, type=AccountObjectType.DELEGATE) + ) + + granted_permission = { + obj["Permission"]["PermissionValue"] + for obj in account_objects_response.result["account_objects"][0][ + "Permissions" + ] + } + + self.assertEqual(len(granted_permission), 2) + self.assertTrue(TransactionType.PAYMENT.value in granted_permission) + self.assertTrue( + GranularPermission.TRUSTLINE_AUTHORIZE.value in granted_permission + ) diff --git a/tests/unit/core/binarycodec/fixtures/data/codec-fixtures.json b/tests/unit/core/binarycodec/fixtures/data/codec-fixtures.json index fa30a927e..5ae4f2c7c 100644 --- a/tests/unit/core/binarycodec/fixtures/data/codec-fixtures.json +++ b/tests/unit/core/binarycodec/fixtures/data/codec-fixtures.json @@ -4839,6 +4839,51 @@ "SigningPubKey": "ED7453D2572A2104E7B266A45888C53F503CEB1F11DC4BB3710EB2995238EC65B8", "TxnSignature": "BC2F6E76969E3747E9BDE183C97573B086212F09D5387460E6EE2F32953E85EAEB9618FBBEF077276E30E59D619FCF7C7BDCDDDD9EB94D7CE1DD5CE9246B2107" } + }, + { + "binary": "120007220000000024000195F964400000170A53AC2065D5460561EC9DE000000000000000000000000000494C53000000000092D705968936C419CE614BF264B5EEB1CEA47FF468400000000000000A7321028472865AF4CB32AA285834B57576B7290AA8C31B459047DB27E16F418D6A71667447304502202ABE08D5E78D1E74A4C18F2714F64E87B8BD57444AFA5733109EB3C077077520022100DB335EE97386E4C0591CAC024D50E9230D8F171EEB901B5E5E4BD6D1E0AEF98C811439408A69F0895E62149CFCC006FB89FA7D1E6E5D", + "json": { + "Account": "raD5qJMAShLeHZXf9wjUmo6vRK4arj9cF3", + "Fee": "10", + "Flags": 0, + "Sequence": 103929, + "SigningPubKey": + "028472865AF4CB32AA285834B57576B7290AA8C31B459047DB27E16F418D6A7166", + "TakerGets": { + "value": "1694.768", + "currency": "ILS", + "issuer": "rNPRNzBB92BVpAhhZr4iXDTveCgV5Pofm9" + }, + "TakerPays": "98957503520", + "TransactionType": "OfferCreate", + "TxnSignature": "304502202ABE08D5E78D1E74A4C18F2714F64E87B8BD57444AFA5733109EB3C077077520022100DB335EE97386E4C0591CAC024D50E9230D8F171EEB901B5E5E4BD6D1E0AEF98C" + } + }, + { + "binary": "120040210000F7E0228000000024000009186840000000000000C87321ED510865F867CDFCB944D435812ACF23D231E5C14534B146BCE46A2F794D198B777440D05A89D0B489DEC1CECBE0D33BA656C929CDCCC75D4D41B282B378544975B87A70C3E42147D980D1F6E2E4DC6316C99D7E2D4F6335F147C71C0DAA0D6516150D8114DB9157872FA63FAF7432CD300911A43B981B85A28514EBA79C385B47C50D52445DF2676EEC0231F732CEF01DEF203400000001E1EF203400000015E1F1", + "json": { + "Account": "rMryaYXZMchTWBovAzEsMzid9FUwmrmcH7", + "Authorize": "r4Vp2qvKR59guHDn4Yzw9owTzDVnt6TJZA", + "Fee": "200", + "Flags": 2147483648, + "NetworkID": 63456, + "Permissions": [ + { + "Permission": { + "PermissionValue": "Payment" + } + }, + { + "Permission": { + "PermissionValue": "TrustSet" + } + } + ], + "Sequence": 2328, + "SigningPubKey": "ED510865F867CDFCB944D435812ACF23D231E5C14534B146BCE46A2F794D198B77", + "TransactionType": "DelegateSet", + "TxnSignature": "D05A89D0B489DEC1CECBE0D33BA656C929CDCCC75D4D41B282B378544975B87A70C3E42147D980D1F6E2E4DC6316C99D7E2D4F6335F147C71C0DAA0D6516150D" + } } ], "ledgerData": [{ diff --git a/tests/unit/models/transactions/test_delegate_set.py b/tests/unit/models/transactions/test_delegate_set.py new file mode 100644 index 000000000..80a372efd --- /dev/null +++ b/tests/unit/models/transactions/test_delegate_set.py @@ -0,0 +1,114 @@ +from unittest import TestCase + +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions import DelegateSet +from xrpl.models.transactions.delegate_set import ( + PERMISSIONS_MAX_LENGTH, + GranularPermission, + Permission, +) +from xrpl.models.transactions.types import TransactionType + +_ACCOUNT = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" +_DELEGATED_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" +_MORE_THAN_10_PERMISSIONS = [ + GranularPermission.PAYMENT_MINT, + GranularPermission.ACCOUNT_MESSAGE_KEY_SET, + GranularPermission.ACCOUNT_TICK_SIZE_SET, + GranularPermission.ACCOUNT_DOMAIN_SET, + TransactionType.PAYMENT, + TransactionType.AMM_CLAWBACK, + TransactionType.AMM_BID, + TransactionType.ORACLE_DELETE, + TransactionType.MPTOKEN_AUTHORIZE, + TransactionType.MPTOKEN_ISSUANCE_DESTROY, + TransactionType.CREDENTIAL_ACCEPT, +] + + +class TestDelegateSet(TestCase): + def test_delegate_set(self): + tx = DelegateSet( + account=_ACCOUNT, + authorize=_DELEGATED_ACCOUNT, + permissions=[ + Permission(permission_value=GranularPermission.TRUSTLINE_AUTHORIZE), + Permission(permission_value=TransactionType.PAYMENT), + ], + ) + self.assertTrue(tx.is_valid()) + + def test_delegate_set_granular_permission(self): + tx = DelegateSet( + account=_ACCOUNT, + authorize=_DELEGATED_ACCOUNT, + permissions=[Permission(permission_value=GranularPermission.PAYMENT_MINT)], + ) + self.assertTrue(tx.is_valid()) + + def test_long_permissions_list(self): + with self.assertRaises(XRPLModelException) as error: + DelegateSet( + account=_ACCOUNT, + authorize=_DELEGATED_ACCOUNT, + permissions=[ + Permission(permission_value=_MORE_THAN_10_PERMISSIONS[i]) + for i in range(len(_MORE_THAN_10_PERMISSIONS)) + ], + ) + self.assertEqual( + error.exception.args[0], + "{'permissions': 'Length of `permissions` list is greater than " + + str(PERMISSIONS_MAX_LENGTH) + + ".'}", + ) + + def test_duplicate_permission_value(self): + with self.assertRaises(XRPLModelException) as error: + DelegateSet( + account=_ACCOUNT, + authorize=_DELEGATED_ACCOUNT, + permissions=[ + Permission(permission_value=TransactionType.ORACLE_DELETE), + Permission(permission_value=TransactionType.ORACLE_DELETE), + ], + ) + self.assertEqual( + error.exception.args[0], + "{'permissions': 'Duplicate permission value in `permissions` list.'}", + ) + + def test_account_and_delegate_are_the_same(self): + with self.assertRaises(XRPLModelException) as error: + DelegateSet( + account=_ACCOUNT, + authorize=_ACCOUNT, + permissions=[ + Permission( + permission_value=GranularPermission.MPTOKEN_ISSUANCE_LOCK + ), + ], + ) + self.assertEqual( + error.exception.args[0], + "{'account_addresses': 'Field `authorize` and `account` must be different." + + "'}", + ) + + def test_non_delegatable_transactions(self): + with self.assertRaises(XRPLModelException) as error: + DelegateSet( + account=_ACCOUNT, + authorize=_DELEGATED_ACCOUNT, + permissions=[ + Permission( + permission_value=GranularPermission.MPTOKEN_ISSUANCE_LOCK + ), + Permission(permission_value=TransactionType.ACCOUNT_DELETE), + ], + ) + self.assertEqual( + error.exception.args[0], + "{'permissions': \"Non-delegatable transactions found in `permissions` " + "list: {}.\"}", + ) diff --git a/tests/unit/models/transactions/test_transaction.py b/tests/unit/models/transactions/test_transaction.py index dea0bb267..387b62ab7 100644 --- a/tests/unit/models/transactions/test_transaction.py +++ b/tests/unit/models/transactions/test_transaction.py @@ -223,3 +223,31 @@ def test_payment_txn_API_identical_amount_and_deliver_max(self): payment_txn = Payment.from_xrpl(payment_tx_json) self.assertEqual(delivered_amount, payment_txn.to_dict()["amount"]) + + def test_duplicate_account_and_delegate_account(self): + with self.assertRaises(XRPLModelException) as err: + Transaction( + account=_ACCOUNT, + delegate=_ACCOUNT, + transaction_type=TransactionType.PAYMENT, + ) + + self.assertEqual( + err.exception.args[0], + "{'delegate': 'Account and delegate addresses cannot be the same'}", + ) + + def test_payment_with_delegate_account(self): + payment_tx_json = { + "Account": "rGWTUVmm1fB5QUjMYn8KfnyrFNgDiD9H9e", + "Destination": "rw71Qs1UYQrSQ9hSgRohqNNQcyjCCfffkQ", + "Delegate": "rJ73aumLPTQQmy5wnGhvrogqf5DDhjuzc9", + "TransactionType": "Payment", + "Amount": "1000000", + "Fee": "15", + "Flags": 0, + "Sequence": 144, + "LastLedgerSequence": 6220218, + } + payment_txn = Payment.from_xrpl(payment_tx_json) + self.assertTrue(payment_txn.is_valid()) diff --git a/xrpl/core/binarycodec/definitions/__init__.py b/xrpl/core/binarycodec/definitions/__init__.py index 75bc00fd7..54de3db99 100644 --- a/xrpl/core/binarycodec/definitions/__init__.py +++ b/xrpl/core/binarycodec/definitions/__init__.py @@ -6,6 +6,8 @@ get_field_name_from_header, get_ledger_entry_type_code, get_ledger_entry_type_name, + get_permission_value_type_code, + get_permission_value_type_name, get_transaction_result_code, get_transaction_result_name, get_transaction_type_code, @@ -30,4 +32,6 @@ "get_transaction_result_name", "get_transaction_type_code", "get_transaction_type_name", + "get_permission_value_type_code", + "get_permission_value_type_name", ] diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index bc86c2c19..557c30aa7 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -670,6 +670,16 @@ "type": "UInt32" } ], + [ + "PermissionValue", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 52, + "type": "UInt32" + } + ], [ "IndexNext", { @@ -1950,6 +1960,16 @@ "type": "AccountID" } ], + [ + "Delegate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 12, + "type": "AccountID" + } + ], [ "HookAccount", { @@ -2170,6 +2190,16 @@ "type": "STObject" } ], + [ + "Permission", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 15, + "type": "STObject" + } + ], [ "Signer", { @@ -2560,6 +2590,16 @@ "type": "STArray" } ], + [ + "Permissions", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 29, + "type": "STArray" + } + ], [ "CloseResolution", { @@ -2879,6 +2919,7 @@ "Check": 67, "Credential": 129, "DID": 73, + "Delegate": 131, "DepositPreauth": 112, "DirectoryNode": 100, "Escrow": 117, @@ -3107,6 +3148,7 @@ "CredentialDelete": 60, "DIDDelete": 50, "DIDSet": 49, + "DelegateSet": 64, "DepositPreauth": 19, "EnableAmendment": 100, "EscrowCancel": 4, diff --git a/xrpl/core/binarycodec/definitions/definitions.py b/xrpl/core/binarycodec/definitions/definitions.py index e82608a09..070129aaf 100644 --- a/xrpl/core/binarycodec/definitions/definitions.py +++ b/xrpl/core/binarycodec/definitions/definitions.py @@ -59,6 +59,32 @@ def load_definitions(filename: str = "definitions.json") -> Dict[str, Any]: value: key for (key, value) in _DEFINITIONS["LEDGER_ENTRY_TYPES"].items() } +_GRANULAR_PERMISSIONS = { + "TrustlineAuthorize": 65537, + "TrustlineFreeze": 65538, + "TrustlineUnfreeze": 65539, + "AccountDomainSet": 65540, + "AccountEmailHashSet": 65541, + "AccountMessageKeySet": 65542, + "AccountTransferRateSet": 65543, + "AccountTickSizeSet": 65544, + "PaymentMint": 65545, + "PaymentBurn": 65546, + "MPTokenIssuanceLock": 65547, + "MPTokenIssuanceUnlock": 65548, +} + +_tx_delegations = { + key: value + 1 for (key, value) in _DEFINITIONS["TRANSACTION_TYPES"].items() +} +_DELEGATABLE_PERMISSIONS_STR_TO_CODE_MAP: Dict[str, int] = { + **_tx_delegations, + **_GRANULAR_PERMISSIONS, +} +_DELEGATABLE_PERMISSIONS_CODE_TO_STR_MAP: Dict[int, str] = { + **{value: key for (key, value) in _DELEGATABLE_PERMISSIONS_STR_TO_CODE_MAP.items()}, +} + _TYPE_ORDINAL_MAP = _DEFINITIONS["TYPES"] _FIELD_INFO_MAP = {} @@ -258,3 +284,29 @@ def get_ledger_entry_type_name(ledger_entry_type: int) -> str: The string name of the ledger entry type. """ return cast(str, _LEDGER_ENTRY_TYPES_CODE_TO_STR_MAP[ledger_entry_type]) + + +def get_permission_value_type_code(permission_value: str) -> int: + """ + Return an integer representing the given permission value string. + + Args: + permission_value: The name of the permission value to get the integer value for. + + Returns: + An integer representing the given permission value string. + """ + return _DELEGATABLE_PERMISSIONS_STR_TO_CODE_MAP[permission_value] + + +def get_permission_value_type_name(permission_value: int) -> str: + """ + Return string representing the given permission value from the integer. + + Args: + permission_value: The integer permission value. + + Returns: + The string name of the permission value. + """ + return _DELEGATABLE_PERMISSIONS_CODE_TO_STR_MAP[permission_value] diff --git a/xrpl/core/binarycodec/types/st_object.py b/xrpl/core/binarycodec/types/st_object.py index e8c6dd12a..b3f013144 100644 --- a/xrpl/core/binarycodec/types/st_object.py +++ b/xrpl/core/binarycodec/types/st_object.py @@ -13,6 +13,8 @@ get_field_instance, get_ledger_entry_type_code, get_ledger_entry_type_name, + get_permission_value_type_code, + get_permission_value_type_name, get_transaction_result_code, get_transaction_result_name, get_transaction_type_code, @@ -68,6 +70,8 @@ def _str_to_enum(field: str, value: str) -> Union[str, int]: return get_transaction_result_code(value) if field == "LedgerEntryType": return get_ledger_entry_type_code(value) + if field == "PermissionValue": + return get_permission_value_type_code(value) return value @@ -79,6 +83,8 @@ def _enum_to_str(field: str, value: int) -> Union[str, int]: return get_transaction_result_name(value) if field == "LedgerEntryType": return get_ledger_entry_type_name(value) + if field == "PermissionValue": + return get_permission_value_type_name(value) return value diff --git a/xrpl/models/requests/account_objects.py b/xrpl/models/requests/account_objects.py index 2ce9f1798..60fbfaf38 100644 --- a/xrpl/models/requests/account_objects.py +++ b/xrpl/models/requests/account_objects.py @@ -24,6 +24,7 @@ class AccountObjectType(str, Enum): CHECK = "check" CREDENTIAL = "credential" DEPOSIT_PREAUTH = "deposit_preauth" + DELEGATE = "delegate" DID = "did" ESCROW = "escrow" MPT_ISSUANCE = "mpt_issuance" diff --git a/xrpl/models/requests/ledger_entry.py b/xrpl/models/requests/ledger_entry.py index 31d42746e..56cc3ed54 100644 --- a/xrpl/models/requests/ledger_entry.py +++ b/xrpl/models/requests/ledger_entry.py @@ -30,6 +30,7 @@ class LedgerEntryType(str, Enum): BRIDGE = "bridge" CHECK = "check" CREDENTIAL = "credential" + DELEGATE = "delegate" DEPOSIT_PREAUTH = "deposit_preauth" DIRECTORY = "directory" DID = "did" @@ -66,6 +67,29 @@ class Credential(BaseModel): """The type of the credential, as issued.""" +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class Delegate(BaseModel): + """ + Required fields for requesting a Delegate ledger object if not querying by + object ID. + """ + + account: str = REQUIRED # type: ignore + """ + The account that wants to authorize another account. + + :meta hide-value: + """ + + authorize: str = REQUIRED # type: ignore + """ + The authorized account. + + :meta hide-value: + """ + + @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) class DepositPreauth(BaseModel): @@ -304,6 +328,7 @@ class LedgerEntry(Request, LookupByLedgerRequest): account_root: Optional[str] = None check: Optional[str] = None credential: Optional[Union[str, Credential]] = None + delegate: Optional[Union[str, Delegate]] = None deposit_preauth: Optional[Union[str, DepositPreauth]] = None did: Optional[str] = None directory: Optional[Union[str, Directory]] = None @@ -338,6 +363,7 @@ def _get_errors(self: Self) -> Dict[str, str]: self.account_root, self.check, self.credential, + self.delegate, self.deposit_preauth, self.did, self.directory, diff --git a/xrpl/models/transactions/__init__.py b/xrpl/models/transactions/__init__.py index 720097c38..fdff2b8a8 100644 --- a/xrpl/models/transactions/__init__.py +++ b/xrpl/models/transactions/__init__.py @@ -32,6 +32,7 @@ from xrpl.models.transactions.credential_accept import CredentialAccept from xrpl.models.transactions.credential_create import CredentialCreate from xrpl.models.transactions.credential_delete import CredentialDelete +from xrpl.models.transactions.delegate_set import DelegateSet, GranularPermission from xrpl.models.transactions.deposit_preauth import DepositPreauth from xrpl.models.transactions.did_delete import DIDDelete from xrpl.models.transactions.did_set import DIDSet @@ -141,11 +142,13 @@ "CredentialCreate", "CredentialDelete", "DepositPreauth", + "DelegateSet", "DIDDelete", "DIDSet", "EscrowCancel", "EscrowCreate", "EscrowFinish", + "GranularPermission", "Memo", "MPTokenAuthorize", "MPTokenAuthorizeFlag", diff --git a/xrpl/models/transactions/delegate_set.py b/xrpl/models/transactions/delegate_set.py new file mode 100644 index 000000000..df8ea6604 --- /dev/null +++ b/xrpl/models/transactions/delegate_set.py @@ -0,0 +1,142 @@ +"""Model for DelegateSet transaction type.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Union + +from typing_extensions import Self + +from xrpl.models.nested_model import NestedModel +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 + +PERMISSIONS_MAX_LENGTH = 10 + +NON_DELEGATABLE_TRANSACTIONS = { + TransactionType.ACCOUNT_SET.value, + TransactionType.SET_REGULAR_KEY.value, + TransactionType.SIGNER_LIST_SET.value, + TransactionType.DELEGATE_SET.value, + TransactionType.ACCOUNT_DELETE.value, +} + + +class GranularPermission(str, Enum): + """ + These permissions would support control over some smaller portion of a transaction, + rather than being able to do all of the functionality that the transaction allows. + """ + + TRUSTLINE_AUTHORIZE = "TrustlineAuthorize" + """Authorize a trustline.""" + + TRUSTLINE_FREEZE = "TrustlineFreeze" + """Freeze a trustline.""" + + TRUSTLINE_UNFREEZE = "TrustlineUnfreeze" + """Unfreeze a trustline.""" + + ACCOUNT_DOMAIN_SET = "AccountDomainSet" + """Modify the domain of an account.""" + + ACCOUNT_EMAIL_HASH_SET = "AccountEmailHashSet" + """Modify the EmailHash of an account.""" + + ACCOUNT_MESSAGE_KEY_SET = "AccountMessageKeySet" + """Modify the MessageKey of an account.""" + + ACCOUNT_TRANSFER_RATE_SET = "AccountTransferRateSet" + """Modify the transfer rate of an account.""" + + ACCOUNT_TICK_SIZE_SET = "AccountTickSizeSet" + """Modify the tick size of an account.""" + + PAYMENT_MINT = "PaymentMint" + """Send a payment for a currency where the sending account is the issuer.""" + + PAYMENT_BURN = "PaymentBurn" + """Send a payment for a currency where the destination account is the issuer.""" + + MPTOKEN_ISSUANCE_LOCK = "MPTokenIssuanceLock" + """Use the MPTIssuanceSet transaction to lock (freeze) a holder.""" + + MPTOKEN_ISSUANCE_UNLOCK = "MPTokenIssuanceUnlock" + """Use the MPTIssuanceSet transaction to unlock (unfreeze) a holder.""" + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class Permission(NestedModel): + """Represents one entry in a Permissions list used in DelegateSet + transaction. + """ + + permission_value: Union[ + TransactionType, GranularPermission + ] = REQUIRED # type: ignore + """ + Transaction level or granular permission. + + :meta hide-value: + """ + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class DelegateSet(Transaction): + """DelegateSet allows an account to delegate a set of permissions to another + account. + """ + + authorize: str = REQUIRED # type: ignore + """The authorized account.""" + + permissions: List[Permission] = REQUIRED # type: ignore + """The transaction permissions that the authorized account has been granted.""" + + transaction_type: TransactionType = field( + default=TransactionType.DELEGATE_SET, + init=False, + ) + """The transaction type (DelegateSet).""" + + def _get_errors(self: Self) -> Dict[str, str]: + return { + key: value + for key, value in { + **super()._get_errors(), + "permissions": self._get_permissions_error(), + "account_addresses": self._validate_account_addresses(), + }.items() + if value is not None + } + + def _validate_account_addresses(self: Self) -> Optional[str]: + if self.authorize == self.account: + return "Field `authorize` and `account` must be different." + return None + + def _get_permissions_error(self: Self) -> Optional[str]: + if len(self.permissions) > PERMISSIONS_MAX_LENGTH: + return ( + f"Length of `permissions` list is greater than " + f"{PERMISSIONS_MAX_LENGTH}." + ) + + entered_permissions = [ + permission.permission_value for permission in self.permissions + ] + if len(entered_permissions) != len(set(entered_permissions)): + return "Duplicate permission value in `permissions` list." + + if set(entered_permissions) & NON_DELEGATABLE_TRANSACTIONS: + return ( + f"Non-delegatable transactions found in `permissions` list: " + f"{set(entered_permissions) & NON_DELEGATABLE_TRANSACTIONS}." + ) + + return None diff --git a/xrpl/models/transactions/transaction.py b/xrpl/models/transactions/transaction.py index 73e1ddfb0..a70d2f81d 100644 --- a/xrpl/models/transactions/transaction.py +++ b/xrpl/models/transactions/transaction.py @@ -25,7 +25,7 @@ def transaction_json_to_binary_codec_form( - dictionary: Dict[str, XRPL_VALUE_TYPE] + dictionary: Dict[str, XRPL_VALUE_TYPE], ) -> Dict[str, XRPL_VALUE_TYPE]: """ Returns a new dictionary in which the keys have been formatted as CamelCase and @@ -253,6 +253,9 @@ class Transaction(BaseModel): network_id: Optional[int] = None """The network id of the transaction.""" + delegate: Optional[str] = None + """The delegate account that is sending the transaction.""" + def _get_errors(self: Self) -> Dict[str, str]: errors = super()._get_errors() if self.ticket_sequence is not None and ( @@ -264,6 +267,9 @@ def _get_errors(self: Self) -> Dict[str, str]: ] = """If ticket_sequence is provided, account_txn_id must be None and sequence must be None or 0""" + if self.account == self.delegate: + errors["delegate"] = "Account and delegate addresses cannot be the same" + return errors def to_dict(self: Self) -> Dict[str, Any]: diff --git a/xrpl/models/transactions/types/transaction_type.py b/xrpl/models/transactions/types/transaction_type.py index 2193a3f11..ee3cd9a75 100644 --- a/xrpl/models/transactions/types/transaction_type.py +++ b/xrpl/models/transactions/types/transaction_type.py @@ -22,6 +22,7 @@ class TransactionType(str, Enum): CREDENTIAL_ACCEPT = "CredentialAccept" CREDENTIAL_CREATE = "CredentialCreate" CREDENTIAL_DELETE = "CredentialDelete" + DELEGATE_SET = "DelegateSet" DEPOSIT_PREAUTH = "DepositPreauth" DID_DELETE = "DIDDelete" DID_SET = "DIDSet"