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 9ea5b9e27..50e95201d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,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..78ec302a0 --- /dev/null +++ b/tests/integration/transactions/test_delegate_set.py @@ -0,0 +1,121 @@ +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 LedgerEntry +from xrpl.models.requests.ledger_entry import Delegate +from xrpl.models.response import ResponseStatus +from xrpl.models.transactions import DelegateSet, Payment +from xrpl.models.transactions.delegate_set import Permission +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 on + # behalf of alice's account. + # Note: Payment transaction has a TransactionType of 0 + permissions=[Permission(permission_value=(1 + 0))], + ) + 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) + + @test_async_and_sync(globals()) + async def test_fetch_delegate_ledger_entry(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 on + # behalf of alice's account. + # Note: Payment transaction has a TransactionType of 0 + permissions=[Permission(permission_value=(1 + 0))], + ) + 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") + + 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"]), 1) 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..850da40b7 --- /dev/null +++ b/tests/unit/models/transactions/test_delegate_set.py @@ -0,0 +1,79 @@ +from unittest import TestCase + +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions import DelegateSet +from xrpl.models.transactions.delegate_set import ( + GRANULAR_PERMISSIONS, + PERMISSION_MAX_LENGTH, + Permission, +) + +_ACCOUNT = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" +_DELEGATED_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" + + +class TestAccountPermissionSet(TestCase): + def test_delegate_set(self): + tx = DelegateSet( + account=_ACCOUNT, + authorize=_DELEGATED_ACCOUNT, + permissions=[Permission(permission_value=1)], + ) + self.assertTrue(tx.is_valid()) + + def test_delegate_set_granular_permission(self): + tx = DelegateSet( + account=_ACCOUNT, + authorize=_DELEGATED_ACCOUNT, + permissions=[ + Permission(permission_value=GRANULAR_PERMISSIONS["PaymentMint"]) + ], + ) + 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=i) + for i in range(PERMISSION_MAX_LENGTH + 1) + ], + ) + self.assertEqual( + error.exception.args[0], + "{'permissions': 'Length of `permissions` list is greater than " + + str(PERMISSION_MAX_LENGTH) + + ".'}", + ) + + def test_duplicate_permission_value(self): + with self.assertRaises(XRPLModelException) as error: + DelegateSet( + account=_ACCOUNT, + authorize=_DELEGATED_ACCOUNT, + permissions=[ + Permission(permission_value=1), + Permission(permission_value=1), + ], + ) + 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=1), + ], + ) + self.assertEqual( + error.exception.args[0], + "{'account_addresses': 'Field `authorize` and `account` must be different." + + "'}", + ) diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index 92e90c8e6..11a7aaafe 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", { @@ -2160,6 +2180,16 @@ "type": "STObject" } ], + [ + "Permission", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 15, + "type": "STObject" + } + ], [ "Signer", { @@ -2549,6 +2579,15 @@ "type": "STArray" } ], + [ + "Permissions", { + "nth": 29, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STArray" + } + ], [ "CloseResolution", { @@ -2868,6 +2907,7 @@ "Check": 67, "DID": 73, "DepositPreauth": 112, + "Delegate": 131, "DirectoryNode": 100, "Escrow": 117, "FeeSettings": 115, @@ -3088,6 +3128,7 @@ "CredentialCreate": 58, "CredentialAccept": 59, "CredentialDelete": 60, + "DelegateSet": 64, "DIDDelete": 50, "DIDSet": 49, "DepositPreauth": 19, diff --git a/xrpl/models/requests/ledger_entry.py b/xrpl/models/requests/ledger_entry.py index 31d42746e..505015141 100644 --- a/xrpl/models/requests/ledger_entry.py +++ b/xrpl/models/requests/ledger_entry.py @@ -66,6 +66,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 +327,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 +362,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..2def7c47a 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 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,6 +142,7 @@ "CredentialCreate", "CredentialDelete", "DepositPreauth", + "DelegateSet", "DIDDelete", "DIDSet", "EscrowCancel", diff --git a/xrpl/models/transactions/delegate_set.py b/xrpl/models/transactions/delegate_set.py new file mode 100644 index 000000000..9c340d0b0 --- /dev/null +++ b/xrpl/models/transactions/delegate_set.py @@ -0,0 +1,99 @@ +"""Model for DelegateSet transaction type.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +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 + +PERMISSION_MAX_LENGTH = 10 + +""" +This is a utility map of granular permission-names to their UINT32 integer values. This +can be used to specify the inputs for `Permission` inner-object (defined below). +""" +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, +} + + +@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: int = REQUIRED # type: ignore + """ + Integer representation of the 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 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) > PERMISSION_MAX_LENGTH: + return ( + f"Length of `permissions` list is greater than {PERMISSION_MAX_LENGTH}." + ) + + # Note: The explicit type-cast into list() is necessary to avoid a + # false-positive due to type mismatch. + if self.permissions != list(set(self.permissions)): + return "Duplicate permission value in `permissions` list." + + return None diff --git a/xrpl/models/transactions/transaction.py b/xrpl/models/transactions/transaction.py index ab1e26940..15d3bab47 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,15 @@ 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: + if "Transaction" in errors: + errors[ + "Transaction" + ] += "Account and delegate addresses cannot be the same" + else: + errors["Transaction"] = ( + "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"