diff --git a/CHANGELOG.md b/CHANGELOG.md index 1be7b6360..afa575673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,17 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [[Unreleased]] + +## [1.9.0] - 2023-06-13 ### Added: - Added `submit_and_wait` to sign (if needed), autofill, submit a transaction and wait for its final outcome - `submit` and `send_reliable_submission` now accept an optional boolean param `fail_hard` (if `True` halt the submission if it's not immediately validated) - Added sidechain devnet support to faucet generation +- Added `user_agent` and `usage_context` to `generate_faucet_wallet` ### Changed: - Allowed keypairs.sign to take a hex string in addition to bytes ### Fixed: - Refactored `does_account_exist` and `get_balance` to avoid deprecated methods and use `ledger_index` parameter -- Fixed crashes in the SignerListSet validation +- Fixed crashes in the `SignerListSet` validation - Improved error messages in `send_reliable_submission` - Better error handling in reliable submission diff --git a/pyproject.toml b/pyproject.toml index 588546c27..1fc8f66f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "xrpl-py" -version = "1.8.0" +version = "1.9.0" description = "A complete Python library for interacting with the XRP ledger" readme = "README.md" repository = "https://github.com/XRPLF/xrpl-py" diff --git a/tests/integration/sugar/test_wallet.py b/tests/integration/sugar/test_wallet.py index 0cfcb308b..ce5a7e9b2 100644 --- a/tests/integration/sugar/test_wallet.py +++ b/tests/integration/sugar/test_wallet.py @@ -15,8 +15,12 @@ time_of_last_hooks_faucet_call = 0 -def sync_generate_faucet_wallet_and_fund_again(self, client, faucet_host=None): - wallet = sync_generate_faucet_wallet(client, faucet_host=faucet_host) +def sync_generate_faucet_wallet_and_fund_again( + self, client, faucet_host=None, usage_context="integration_test" +): + wallet = sync_generate_faucet_wallet( + client, faucet_host=faucet_host, usage_context=usage_context + ) result = client.request( AccountInfo( account=wallet.classic_address, @@ -25,7 +29,9 @@ def sync_generate_faucet_wallet_and_fund_again(self, client, faucet_host=None): balance = int(result.result["account_data"]["Balance"]) self.assertTrue(balance > 0) - new_wallet = sync_generate_faucet_wallet(client, wallet, faucet_host=faucet_host) + new_wallet = sync_generate_faucet_wallet( + client, wallet, faucet_host=faucet_host, usage_context="integration_test" + ) new_result = client.request( AccountInfo( account=new_wallet.classic_address, @@ -35,8 +41,12 @@ def sync_generate_faucet_wallet_and_fund_again(self, client, faucet_host=None): self.assertTrue(new_balance > balance) -async def generate_faucet_wallet_and_fund_again(self, client, faucet_host=None): - wallet = await generate_faucet_wallet(client, faucet_host=faucet_host) +async def generate_faucet_wallet_and_fund_again( + self, client, faucet_host=None, usage_context="integration_test" +): + wallet = await generate_faucet_wallet( + client, faucet_host=faucet_host, usage_context=usage_context + ) result = await client.request( AccountInfo( account=wallet.classic_address, @@ -45,7 +55,9 @@ async def generate_faucet_wallet_and_fund_again(self, client, faucet_host=None): balance = int(result.result["account_data"]["Balance"]) self.assertTrue(balance > 0) - new_wallet = await generate_faucet_wallet(client, wallet, faucet_host=faucet_host) + new_wallet = await generate_faucet_wallet( + client, wallet, faucet_host=faucet_host, usage_context=usage_context + ) new_result = await client.request( AccountInfo( account=new_wallet.classic_address, @@ -106,13 +118,19 @@ async def _parallel_test_generate_faucet_wallet_custom_host_async_websockets(sel "wss://s.devnet.rippletest.net:51233" ) as client: await generate_faucet_wallet_and_fund_again( - self, client, "faucet.devnet.rippletest.net" + self, + client, + "faucet.devnet.rippletest.net", + usage_context="integration_test", ) async def _parallel_test_generate_faucet_wallet_custom_host_async_json_rpc(self): client = AsyncJsonRpcClient("https://s.devnet.rippletest.net:51234") await generate_faucet_wallet_and_fund_again( - self, client, "faucet.devnet.rippletest.net" + self, + client, + "faucet.devnet.rippletest.net", + usage_context="integration_test", ) def _parallel_test_generate_faucet_wallet_custom_host_sync_websockets(self): @@ -167,7 +185,9 @@ async def _parallel_test_generate_faucet_wallet_hooks_v3_testnet_async_websocket if time_since_last_hooks_call < 10: time.sleep(11 - time_since_last_hooks_call) - wallet = await generate_faucet_wallet(client) + wallet = await generate_faucet_wallet( + client, usage_context="integration_test" + ) time_of_last_hooks_faucet_call = time.time() result = await client.request( @@ -205,7 +225,9 @@ async def test_fund_given_wallet_hooks_v3_testnet_async_websockets(self): time.sleep(11 - time_since_last_hooks_call) time_of_last_hooks_faucet_call = time.time() - new_wallet = await generate_faucet_wallet(client, wallet) + new_wallet = await generate_faucet_wallet( + client, wallet, usage_context="integration_test" + ) new_result = await client.request( AccountInfo( account=new_wallet.classic_address, diff --git a/tests/unit/models/transactions/test_autofill.py b/tests/unit/models/transactions/test_autofill.py new file mode 100644 index 000000000..491194808 --- /dev/null +++ b/tests/unit/models/transactions/test_autofill.py @@ -0,0 +1,68 @@ +from unittest import TestCase + +from xrpl.asyncio.transaction.main import _RESTRICTED_NETWORKS +from xrpl.clients import JsonRpcClient, WebsocketClient +from xrpl.models.transactions import AccountSet +from xrpl.transaction import autofill +from xrpl.wallet.wallet_generation import generate_faucet_wallet + +_FEE = "0.00001" + + +class TestAutofill(TestCase): + # Autofill should override tx networkID for network with ID > 1024 + # and build_version from 1.11.0 or later. + def test_networkid_override(self): + client = JsonRpcClient("https://sidechain-net1.devnet.rippletest.net:51234") + wallet = generate_faucet_wallet(client, debug=True) + # Override client build_version since 1.11.0 is not released yet. + client.build_version = "1.11.0" + tx = AccountSet( + account=wallet.classic_address, + fee=_FEE, + domain="www.example.com", + ) + tx_autofilled = autofill(tx, client) + self.assertGreaterEqual(client.network_id, _RESTRICTED_NETWORKS) + self.assertEqual(tx_autofilled.network_id, client.network_id) + + # Autofill should ignore tx network_id for build version earlier than 1.11.0. + def test_networkid_ignore_early_version(self): + client = JsonRpcClient("https://sidechain-net1.devnet.rippletest.net:51234") + wallet = generate_faucet_wallet(client, debug=True) + # Override client build_version since 1.11.0 is not released yet. + client.build_version = "1.10.0" + tx = AccountSet( + account=wallet.classic_address, + fee=_FEE, + domain="www.example.com", + ) + tx_autofilled = autofill(tx, client) + self.assertEqual(tx_autofilled.network_id, None) + + # Autofill should ignore tx network_id for networks with ID <= 1024. + def test_networkid_ignore_restricted_networks(self): + client = JsonRpcClient("https://s.altnet.rippletest.net:51234") + wallet = generate_faucet_wallet(client, debug=True) + # Override client build_version since 1.11.0 is not released yet. + client.build_version = "1.11.0" + tx = AccountSet( + account=wallet.classic_address, + fee=_FEE, + domain="www.example.com", + ) + tx_autofilled = autofill(tx, client) + self.assertLessEqual(client.network_id, _RESTRICTED_NETWORKS) + self.assertEqual(tx_autofilled.network_id, None) + + # Autofill should override tx networkID for hooks-testnet. + def test_networkid_override_hooks_testnet(self): + with WebsocketClient("wss://hooks-testnet-v3.xrpl-labs.com") as client: + wallet = generate_faucet_wallet(client, debug=True) + tx = AccountSet( + account=wallet.classic_address, + fee=_FEE, + domain="www.example.com", + ) + tx_autofilled = autofill(tx, client) + self.assertEqual(tx_autofilled.network_id, client.network_id) diff --git a/xrpl/asyncio/clients/client.py b/xrpl/asyncio/clients/client.py index 54819ae4f..15acadd2c 100644 --- a/xrpl/asyncio/clients/client.py +++ b/xrpl/asyncio/clients/client.py @@ -2,6 +2,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from typing import Optional from xrpl.models.requests.request import Request from xrpl.models.response import Response @@ -22,6 +23,8 @@ def __init__(self: Client, url: str) -> None: url: The url to which this client will connect """ self.url = url + self.network_id: Optional[int] = None + self.build_version: Optional[str] = None @abstractmethod async def _request_impl(self: Client, request: Request) -> Response: diff --git a/xrpl/asyncio/transaction/main.py b/xrpl/asyncio/transaction/main.py index 15d3e08e7..821ddedab 100644 --- a/xrpl/asyncio/transaction/main.py +++ b/xrpl/asyncio/transaction/main.py @@ -11,7 +11,7 @@ from xrpl.core.addresscodec import is_valid_xaddress, xaddress_to_classic_address from xrpl.core.binarycodec import encode, encode_for_multisigning, encode_for_signing from xrpl.core.keypairs.main import sign as keypairs_sign -from xrpl.models.requests import ServerState, SubmitOnly +from xrpl.models.requests import ServerInfo, ServerState, SubmitOnly from xrpl.models.response import Response from xrpl.models.transactions import EscrowFinish from xrpl.models.transactions.transaction import Signer, Transaction @@ -23,7 +23,14 @@ from xrpl.wallet.main import Wallet _LEDGER_OFFSET: Final[int] = 20 - +# Sidechains are expected to have network IDs above this. +# Networks with ID above this restricted number are expected to specify an +# accurate NetworkID field in every transaction to that chain to prevent replay attacks. +# Mainnet and testnet are exceptions. +# More context: https://github.com/XRPLF/rippled/pull/4370 +_RESTRICTED_NETWORKS = 1024 +_REQUIRED_NETWORKID_VERSION = "1.11.0" +_HOOKS_TESTNET_ID = 21338 # TODO: make this dynamic based on the current ledger fee _ACCOUNT_DELETE_FEE: Final[int] = int(xrp_to_drops(2)) @@ -235,6 +242,10 @@ async def autofill( The autofilled transaction. """ transaction_json = transaction.to_dict() + if not client.network_id: + await _get_network_id_and_build_version(client) + if "network_id" not in transaction_json and _tx_needs_networkID(client): + transaction_json["network_id"] = client.network_id if "sequence" not in transaction_json: sequence = await get_next_valid_seq_number(transaction_json["account"], client) transaction_json["sequence"] = sequence @@ -248,6 +259,105 @@ async def autofill( return Transaction.from_dict(transaction_json) +async def _get_network_id_and_build_version(client: Client) -> None: + """ + Get the network id and build version of the connected server. + + Args: + client: The network client to use to send the request. + + Raises: + XRPLRequestFailureException: if the rippled API call fails. + """ + response = await client._request_impl(ServerInfo()) + if response.is_successful(): + if "network_id" in response.result["info"]: + client.network_id = response.result["info"]["network_id"] + if not client.build_version and "build_version" in response.result["info"]: + client.build_version = response.result["info"]["build_version"] + return + + raise XRPLRequestFailureException(response.result) + + +def _tx_needs_networkID(client: Client) -> bool: + """ + Determines whether the transactions required network ID to be valid. + Transaction needs networkID if later than restricted ID and either + the network is hooks testnet or build version is >= 1.11.0. + More context: https://github.com/XRPLF/rippled/pull/4370 + + Args: + client (Client): The network client to use to send the request. + + Returns: + bool: whether the transactions required network ID to be valid + """ + if client.network_id and client.network_id > _RESTRICTED_NETWORKS: + # TODO: remove the buildVersion logic when 1.11.0 is out and widely used. + # Issue: https://github.com/XRPLF/xrpl-py/issues/595 + if ( + client.build_version + and _is_not_later_rippled_version( + _REQUIRED_NETWORKID_VERSION, client.build_version + ) + ) or client.network_id == _HOOKS_TESTNET_ID: + return True + return False + + +def _is_not_later_rippled_version(source: str, target: str) -> bool: + """ + Determines whether the source version is not a later release than the + target version. + + Args: + source: the source rippled version. + target: the target rippled version. + + Returns: + bool: true if source is earlier, false otherwise. + """ + if source == target: + return True + source_decomp = source.split(".") + target_decomp = target.split(".") + source_major, source_minor = int(source_decomp[0]), int(source_decomp[1]) + target_major, target_minor = int(target_decomp[0]), int(target_decomp[1]) + + # Compare major version + if source_major != target_major: + return source_major < target_major + + # Compare minor version + if source_minor != target_minor: + return source_minor < target_minor + + source_patch = source_decomp[2].split("-") + target_patch = target_decomp[2].split("-") + source_patch_version = int(source_patch[0]) + target_patch_version = int(target_patch[0]) + + # Compare patch version + if source_patch_version != target_patch_version: + return source_patch_version < target_patch_version + + # Compare release version + if len(source_patch) != len(target_patch): + return len(source_patch) > len(target_patch) + + if len(source_patch) == 2: + # Compare release types + if not source_patch[1][0].startswith(target_patch[1][0]): + return source_patch[1] < target_patch[1] + # Compare beta versions + if source_patch[1].startswith("b"): + return int(source_patch[1][1:]) < int(target_patch[1][1:]) + # Compare rc versions + return int(source_patch[1][2:]) < int(target_patch[1][2:]) + return False + + def _validate_account_xaddress( json: Dict[str, Any], account_field: str, tag_field: str ) -> None: diff --git a/xrpl/asyncio/wallet/wallet_generation.py b/xrpl/asyncio/wallet/wallet_generation.py index 515e0d30c..a64677bc3 100644 --- a/xrpl/asyncio/wallet/wallet_generation.py +++ b/xrpl/asyncio/wallet/wallet_generation.py @@ -34,6 +34,8 @@ async def generate_faucet_wallet( wallet: Optional[Wallet] = None, debug: bool = False, faucet_host: Optional[str] = None, + usage_context: Optional[str] = None, + user_agent: Optional[str] = "xrpl-py", ) -> Wallet: """ Generates a random wallet and funds it using the XRPL Testnet Faucet. @@ -44,6 +46,12 @@ async def generate_faucet_wallet( debug: Whether to print debug information as it creates the wallet. faucet_host: A custom host to use for funding a wallet. In environments other than devnet and testnet, this parameter is required. + usage_context: The intended use case for the funding request + (for example, testing). This information will be included + in the json body of the HTTP request to the faucet. + user_agent: A string representing the user agent (software/ client used) + for the HTTP request. Default is "xrpl-py". + Returns: A Wallet on the testnet that contains some amount of XRP. @@ -69,7 +77,7 @@ async def generate_faucet_wallet( starting_balance = await _check_wallet_balance(address, client) # Ask the faucet to send funds to the given address - await _request_funding(faucet_url, address) + await _request_funding(faucet_url, address, usage_context, user_agent) # Wait for the faucet to fund our account or until timeout # Waits one second checks if balance has changed # If balance doesn't change it will attempt again until _TIMEOUT_SECONDS @@ -143,9 +151,17 @@ async def _check_wallet_balance(address: str, client: Client) -> int: raise -async def _request_funding(url: str, address: str) -> None: +async def _request_funding( + url: str, + address: str, + usage_context: Optional[str] = None, + user_agent: Optional[str] = None, +) -> None: async with httpx.AsyncClient() as http_client: - response = await http_client.post(url=url, json={"destination": address}) + json_body = {"destination": address, "userAgent": user_agent} + if usage_context is not None: + json_body["usageContext"] = usage_context + response = await http_client.post(url=url, json=json_body) if not response.status_code == httpx.codes.OK: response.raise_for_status() diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index 317e1feb7..a59de992e 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -321,6 +321,16 @@ "type": "UInt16" } ], + [ + "NetworkID", + { + "nth": 1, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt32" + } + ], [ "Flags", { @@ -2176,6 +2186,9 @@ "telCAN_NOT_QUEUE_BLOCKED": -389, "telCAN_NOT_QUEUE_FEE": -388, "telCAN_NOT_QUEUE_FULL": -387, + "telWRONG_NETWORK": -386, + "telREQUIRES_NETWORK_ID": -385, + "telNETWORK_ID_MAKES_TX_NON_CANONICAL": -384, "temMALFORMED": -299, "temBAD_AMOUNT": -298, diff --git a/xrpl/models/transactions/transaction.py b/xrpl/models/transactions/transaction.py index d03d0f4e1..899c67356 100644 --- a/xrpl/models/transactions/transaction.py +++ b/xrpl/models/transactions/transaction.py @@ -255,6 +255,9 @@ class Transaction(BaseModel): transaction. Automatically added during signing. """ + network_id: Optional[int] = None + """The network id of the transaction.""" + def _get_errors(self: Transaction) -> Dict[str, str]: errors = super()._get_errors() if self.ticket_sequence is not None and ( diff --git a/xrpl/wallet/wallet_generation.py b/xrpl/wallet/wallet_generation.py index 8f24809a0..af9511962 100644 --- a/xrpl/wallet/wallet_generation.py +++ b/xrpl/wallet/wallet_generation.py @@ -12,6 +12,7 @@ def generate_faucet_wallet( wallet: Optional[Wallet] = None, debug: bool = False, faucet_host: Optional[str] = None, + usage_context: Optional[str] = None, ) -> Wallet: """ Generates a random wallet and funds it using the XRPL Testnet Faucet. @@ -22,6 +23,9 @@ def generate_faucet_wallet( debug: Whether to print debug information as it creates the wallet. faucet_host: A custom host to use for funding a wallet. In environments other than devnet and testnet, this parameter is required. + usage_context: The intended use case for the funding request + (for example, testing). This information will be included in json body + of the HTTP request to the faucet. Returns: A Wallet on the testnet that contains some amount of XRP. @@ -33,4 +37,6 @@ def generate_faucet_wallet( .. # noqa: DAR402 exception raised in private method """ - return asyncio.run(async_generate_faucet_wallet(client, wallet, debug, faucet_host)) + return asyncio.run( + async_generate_faucet_wallet(client, wallet, debug, faucet_host, usage_context) + )