From 972177d467ab24a938b6472ff40241d10c0a3567 Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Fri, 16 Jun 2023 21:41:14 +0000 Subject: [PATCH] feature `NetworkID` (#520) Added network id to the base transaction Added network id to the server_info response Added network id to the binary codec Added network id to autofill if custom network id was supplied to client. Co-authored-by: pdp2121 <71317875+pdp2121@users.noreply.github.com> --- .../unit/models/transactions/test_autofill.py | 68 +++++++++++ xrpl/asyncio/clients/client.py | 3 + xrpl/asyncio/transaction/main.py | 114 +++++++++++++++++- .../binarycodec/definitions/definitions.json | 13 ++ xrpl/models/transactions/transaction.py | 3 + 5 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 tests/unit/models/transactions/test_autofill.py 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/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 (