From 42f420bc9860b121cd6cc10c995e29dc9e1704f4 Mon Sep 17 00:00:00 2001 From: Tushar Pardhe Date: Thu, 4 Sep 2025 17:12:55 +0530 Subject: [PATCH 1/6] feat (846): added ledger object hashing functionality --- tests/unit/utils/test_hash_utils.py | 323 ++++++++++++++++++++++++++++ xrpl/utils/__init__.py | 27 +++ xrpl/utils/hash_utils.py | 272 +++++++++++++++++++++++ 3 files changed, 622 insertions(+) create mode 100644 tests/unit/utils/test_hash_utils.py create mode 100644 xrpl/utils/hash_utils.py diff --git a/tests/unit/utils/test_hash_utils.py b/tests/unit/utils/test_hash_utils.py new file mode 100644 index 000000000..42bffffa1 --- /dev/null +++ b/tests/unit/utils/test_hash_utils.py @@ -0,0 +1,323 @@ +"""Tests for hash utilities.""" + +from unittest import TestCase + +from xrpl.core.addresscodec.exceptions import XRPLAddressCodecException +from xrpl.utils.hash_utils import ( + LEDGER_SPACES, + address_to_hex, + hash_account_root, + hash_check, + hash_deposit_preauth, + hash_escrow, + hash_offer, + hash_offer_id, + hash_payment_channel, + hash_ripple_state, + hash_signer_list_id, + hash_ticket, + hash_trustline, + hash_uri_token, + ledger_space_hex, + sha512_half, +) + + +class TestHashUtilsBasic(TestCase): + """Test basic hash utility functions.""" + + def test_sha512_half(self): + """Test SHA512 half hash function.""" + # Test with known input/output + test_data = "48656C6C6F20576F726C64" # "Hello World" in hex + result = sha512_half(test_data) + # Should be 64 characters (32 bytes) uppercase hex + self.assertEqual(len(result), 64) + self.assertTrue(result.isupper()) + self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) + + def test_address_to_hex(self): + """Test address to hex conversion.""" + address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + result = address_to_hex(address) + # Should be 40 characters (20 bytes) hex + self.assertEqual(len(result), 40) + # Should be valid hex and uppercase + self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) + + def test_address_to_hex_invalid(self): + """Test address to hex conversion with invalid address.""" + with self.assertRaises((XRPLAddressCodecException, ValueError)): + address_to_hex("invalid_address") + + def test_ledger_space_hex(self): + """Test ledger space hex conversion.""" + # Test known ledger spaces + self.assertEqual(ledger_space_hex("account"), "0061") # 'a' = 0x61 + self.assertEqual(ledger_space_hex("offer"), "006f") # 'o' = 0x6f + self.assertEqual(ledger_space_hex("escrow"), "0075") # 'u' = 0x75 + self.assertEqual(ledger_space_hex("check"), "0043") # 'C' = 0x43 + self.assertEqual(ledger_space_hex("paychan"), "0078") # 'x' = 0x78 + + def test_ledger_space_hex_invalid(self): + """Test ledger space hex with invalid space.""" + with self.assertRaises(KeyError): + ledger_space_hex("invalid_space") + + +class TestHashFunctions(TestCase): + """Test individual hash functions with known test vectors from xrpl.js.""" + + def test_hash_account_root(self): + """Test account root hash calculation.""" + # Test vector from xrpl.js tests + address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + expected = "2B6AC232AA4C4BE41BF49D2459FA4A0347E1B543A4C92FCEE0821C0201E2E9A8" + result = hash_account_root(address) + self.assertEqual(result, expected) + + def test_hash_offer(self): + """Test offer hash calculation.""" + # Test with a simple case first + address = "r32UufnaCGL82HubijgJGDmdE5hac7ZvLw" + sequence = 137 + result = hash_offer(address, sequence) + # Should return a 64-character uppercase hex string + self.assertEqual(len(result), 64) + self.assertTrue(result.isupper()) + self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) + + def test_hash_offer_alias(self): + """Test that hash_offer_id is an alias for hash_offer.""" + address = "r32UufnaCGL82HubijgJGDmdE5hac7ZvLw" + sequence = 137 + self.assertEqual(hash_offer(address, sequence), hash_offer_id(address, sequence)) + + def test_hash_signer_list_id(self): + """Test signer list ID hash calculation.""" + # Test vector from xrpl.js tests + address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + expected = "778365D5180F5DF3016817D1F318527AD7410D83F8636CF48C43E8AF72AB49BF" + result = hash_signer_list_id(address) + self.assertEqual(result, expected) + + def test_hash_escrow(self): + """Test escrow hash calculation.""" + # Test with valid addresses + address = "rDx69ebzbowuqztksVDmZXjizTd12BVr4x" + sequence = 84 + result = hash_escrow(address, sequence) + # Should return a 64-character uppercase hex string + self.assertEqual(len(result), 64) + self.assertTrue(result.isupper()) + self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) + + def test_hash_payment_channel(self): + """Test payment channel hash calculation.""" + # Test with valid addresses + address = "rDx69ebzbowuqztksVDmZXjizTd12BVr4x" + dst_address = "rLFtVprxUEfsH54eCWKsZrEQzMDsx1wqso" + sequence = 82 + result = hash_payment_channel(address, dst_address, sequence) + # Should return a 64-character uppercase hex string + self.assertEqual(len(result), 64) + self.assertTrue(result.isupper()) + self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) + + def test_hash_trustline(self): + """Test trustline hash calculation.""" + # Test vector from xrpl.js tests + address1 = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + address2 = "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY" + currency = "USD" + expected = "C683B5BB928F025F1E860D9D69D6C554C2202DE0D45877ADB3077DA4CB9E125C" + result = hash_trustline(address1, address2, currency) + self.assertEqual(result, expected) + + def test_hash_trustline_ordering(self): + """Test that trustline hash is consistent regardless of address order.""" + address1 = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + address2 = "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY" + currency = "USD" + + # Should produce same hash regardless of order + result1 = hash_trustline(address1, address2, currency) + result2 = hash_trustline(address2, address1, currency) + self.assertEqual(result1, result2) + + def test_hash_trustline_alias(self): + """Test that hash_ripple_state is an alias for hash_trustline.""" + address1 = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + address2 = "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY" + currency = "USD" + self.assertEqual(hash_trustline(address1, address2, currency), + hash_ripple_state(address1, address2, currency)) + + def test_hash_check(self): + """Test check hash calculation.""" + # Use a valid XRPL address + address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + sequence = 1 + result = hash_check(address, sequence) + # Should return a 64-character uppercase hex string + self.assertEqual(len(result), 64) + self.assertTrue(result.isupper()) + self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) + + def test_hash_ticket(self): + """Test ticket hash calculation.""" + # Use a valid XRPL address + address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + ticket_id = 25 + result = hash_ticket(address, ticket_id) + # Should return a 64-character uppercase hex string + self.assertEqual(len(result), 64) + self.assertTrue(result.isupper()) + self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) + + def test_hash_deposit_preauth(self): + """Test deposit preauth hash calculation.""" + # Use valid XRPL addresses + address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + authorized_address = "rDx69ebzbowuqztksVDmZXjizTd12BVr4x" + result = hash_deposit_preauth(address, authorized_address) + # Should return a 64-character uppercase hex string + self.assertEqual(len(result), 64) + self.assertTrue(result.isupper()) + self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) + + def test_hash_uri_token(self): + """Test URI token hash calculation.""" + # Use a valid XRPL address + issuer = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + uri = "https://example.com/token" + result = hash_uri_token(issuer, uri) + # Should return a 64-character uppercase hex string + self.assertEqual(len(result), 64) + self.assertTrue(result.isupper()) + self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) + + +class TestEdgeCases(TestCase): + """Test edge cases and error conditions.""" + + def test_sequence_zero(self): + """Test hash functions with sequence number 0.""" + address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + + # These should all work with sequence 0 + result_offer = hash_offer(address, 0) + self.assertEqual(len(result_offer), 64) + + result_escrow = hash_escrow(address, 0) + self.assertEqual(len(result_escrow), 64) + + result_check = hash_check(address, 0) + self.assertEqual(len(result_check), 64) + + def test_large_sequence_number(self): + """Test hash functions with large sequence numbers.""" + address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + large_sequence = 2**32 - 1 # Maximum 32-bit unsigned integer + + result = hash_offer(address, large_sequence) + self.assertEqual(len(result), 64) + self.assertTrue(result.isupper()) + + def test_invalid_address_in_hash_functions(self): + """Test hash functions with invalid addresses.""" + invalid_address = "invalid_address" + + with self.assertRaises((XRPLAddressCodecException, ValueError)): + hash_account_root(invalid_address) + + with self.assertRaises((XRPLAddressCodecException, ValueError)): + hash_offer(invalid_address, 123) + + with self.assertRaises((XRPLAddressCodecException, ValueError)): + hash_escrow(invalid_address, 123) + + def test_empty_uri(self): + """Test URI token hash with empty URI.""" + # Use a valid XRPL address + issuer = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + uri = "" + result = hash_uri_token(issuer, uri) + # Should still return a valid hash + self.assertEqual(len(result), 64) + self.assertTrue(result.isupper()) + + def test_currency_formats(self): + """Test different currency formats in trustline hash.""" + address1 = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + address2 = "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY" + + # Standard 3-character currency + result_usd = hash_trustline(address1, address2, "USD") + self.assertEqual(len(result_usd), 64) + + # Different currency + result_eur = hash_trustline(address1, address2, "EUR") + self.assertEqual(len(result_eur), 64) + self.assertNotEqual(result_usd, result_eur) + + # Test currency as bytes (covers the bytes case) + currency_bytes = b"USD" + result_bytes = hash_trustline(address1, address2, currency_bytes) + self.assertEqual(len(result_bytes), 64) + self.assertTrue(result_bytes.isupper()) + + # Test currency as hex string (covers the else case for non-3-char strings) + currency_hex = "0000000000000000555344000000000000000000" # USD as hex + result_hex = hash_trustline(address1, address2, currency_hex) + self.assertEqual(len(result_hex), 64) + self.assertTrue(result_hex.isupper()) + + def test_ledger_spaces_completeness(self): + """Test that all expected ledger spaces are defined.""" + expected_spaces = [ + "account", "dirNode", "generatorMap", "rippleState", "offer", + "ownerDir", "bookDir", "contract", "skipList", "escrow", + "amendment", "feeSettings", "ticket", "signerList", "paychan", + "check", "uriToken", "depositPreauth" + ] + + for space in expected_spaces: + self.assertIn(space, LEDGER_SPACES) + # Should be able to get hex for each space + hex_result = ledger_space_hex(space) + self.assertEqual(len(hex_result), 4) + self.assertTrue(all(c in "0123456789abcdef" for c in hex_result.lower())) + + def test_hash_consistency(self): + """Test that hash functions produce consistent results.""" + address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + + # Same input should produce same output + result1 = hash_account_root(address) + result2 = hash_account_root(address) + self.assertEqual(result1, result2) + + # Same offer parameters should produce same hash + offer1 = hash_offer(address, 123) + offer2 = hash_offer(address, 123) + self.assertEqual(offer1, offer2) + + # Different sequence should produce different hash + offer3 = hash_offer(address, 124) + self.assertNotEqual(offer1, offer3) + + def test_sequence_formatting(self): + """Test that sequence numbers are formatted correctly.""" + address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + + # Test small sequence number + result_small = hash_offer(address, 1) + self.assertEqual(len(result_small), 64) + + # Test larger sequence number + result_large = hash_offer(address, 1000000) + self.assertEqual(len(result_large), 64) + + # Results should be different + self.assertNotEqual(result_small, result_large) \ No newline at end of file diff --git a/xrpl/utils/__init__.py b/xrpl/utils/__init__.py index 6a0ae8a7f..2ed65623b 100644 --- a/xrpl/utils/__init__.py +++ b/xrpl/utils/__init__.py @@ -2,6 +2,20 @@ from xrpl.utils.get_nftoken_id import get_nftoken_id from xrpl.utils.get_xchain_claim_id import get_xchain_claim_id +from xrpl.utils.hash_utils import ( + hash_account_root, + hash_check, + hash_deposit_preauth, + hash_escrow, + hash_offer, + hash_offer_id, + hash_payment_channel, + hash_ripple_state, + hash_signer_list_id, + hash_ticket, + hash_trustline, + hash_uri_token, +) from xrpl.utils.parse_nftoken_id import parse_nftoken_id from xrpl.utils.str_conversions import hex_to_str, str_to_hex from xrpl.utils.time_conversions import ( @@ -35,4 +49,17 @@ "get_nftoken_id", "parse_nftoken_id", "get_xchain_claim_id", + # Ledger Object Hash functions + "hash_account_root", + "hash_check", + "hash_deposit_preauth", + "hash_escrow", + "hash_offer", + "hash_offer_id", + "hash_payment_channel", + "hash_ripple_state", + "hash_signer_list_id", + "hash_ticket", + "hash_trustline", + "hash_uri_token", ] diff --git a/xrpl/utils/hash_utils.py b/xrpl/utils/hash_utils.py new file mode 100644 index 000000000..39ab303d1 --- /dev/null +++ b/xrpl/utils/hash_utils.py @@ -0,0 +1,272 @@ +"""Hash Utilities for XRPL Ledger Objects. + +This module provides utilities for computing hashes of various XRPL ledger objects. +These functions are essential for Batch transactions where you need to know the hash +of a ledger entry object before it's created on the ledger. + +Example: + >>> from xrpl.utils.hash_utils import hash_offer, hash_escrow + >>> offer_hash = hash_offer("rAccount...", 123) + >>> escrow_hash = hash_escrow("rAccount...", 456) +""" + +import hashlib +from typing import Union + +from xrpl.core import addresscodec +from xrpl.utils.str_conversions import str_to_hex + +# Constants +HEX = 16 +BYTE_LENGTH = 4 # 4 bytes for sequence numbers + +# Ledger space dictionary mapping ledger object types to their namespace identifiers +LEDGER_SPACES = { + "account": "a", + "dirNode": "d", + "generatorMap": "g", + "rippleState": "r", + "offer": "o", + "ownerDir": "O", + "bookDir": "B", + "contract": "c", + "skipList": "s", + "escrow": "u", + "amendment": "f", + "feeSettings": "e", + "ticket": "T", + "signerList": "S", + "paychan": "x", + "check": "C", + "uriToken": "U", + "depositPreauth": "p", +} + + +def sha512_half(data: str) -> str: + """Compute the SHA-512 hash and then take the first half of the result. + + Args: + data: The input data in hexadecimal format. + + Returns: + The first half of the SHA-512 hash in uppercase hexadecimal. + """ + hash_obj = hashlib.sha512(bytes.fromhex(data)) + return hash_obj.hexdigest()[:64].upper() + + +def address_to_hex(address: str) -> str: + """Convert an XRPL address to its hexadecimal representation. + + Args: + address: The classic XRPL address to convert (starts with 'r'). + + Returns: + The hexadecimal representation of the address. + + Raises: + XRPLAddressCodecException: If the address is invalid. + """ + return addresscodec.decode_classic_address(address).hex().upper() + + +def ledger_space_hex(name: str) -> str: + """Get the hexadecimal representation of a ledger space. + + Args: + name: The name of the ledger space. + + Returns: + The hexadecimal representation of the ledger space (4 characters). + + Raises: + KeyError: If the ledger space name is not recognized. + """ + return format(ord(LEDGER_SPACES[name]), "x").zfill(4) + + +def hash_account_root(address: str) -> str: + """Compute the hash of an AccountRoot ledger entry. + + Args: + address: The classic account address. + + Returns: + The computed hash of the account root in uppercase hexadecimal. + """ + return sha512_half(ledger_space_hex("account") + address_to_hex(address)) + + +def hash_offer(address: str, sequence: int) -> str: + """Compute the hash of an Offer ledger entry. + + Args: + address: The address associated with the offer. + sequence: The sequence number of the offer transaction. + + Returns: + The computed hash of the offer in uppercase hexadecimal. + """ + return sha512_half( + ledger_space_hex("offer") + + address_to_hex(address) + + format(sequence, "x").zfill(8) # 4 bytes = 8 hex characters + ) + + +def hash_check(address: str, sequence: int) -> str: + """Compute the hash of a Check ledger entry. + + Args: + address: The address associated with the check. + sequence: The sequence number of the check transaction. + + Returns: + The computed hash of the check in uppercase hexadecimal. + """ + return sha512_half( + ledger_space_hex("check") + + address_to_hex(address) + + format(sequence, "x").zfill(8) # 4 bytes = 8 hex characters + ) + + +def hash_escrow(address: str, sequence: int) -> str: + """Compute the hash of an Escrow ledger entry. + + Args: + address: The address associated with the escrow. + sequence: The sequence number of the escrow transaction. + + Returns: + The computed hash of the escrow in uppercase hexadecimal. + """ + return sha512_half( + ledger_space_hex("escrow") + + address_to_hex(address) + + format(sequence, "x").zfill(8) # 4 bytes = 8 hex characters + ) + + +def hash_payment_channel(address: str, dst_address: str, sequence: int) -> str: + """Compute the hash of a Payment Channel ledger entry. + + Args: + address: The address of the payment channel creator. + dst_address: The destination address for the payment channel. + sequence: The sequence number of the payment channel transaction. + + Returns: + The computed hash of the payment channel in uppercase hexadecimal. + """ + return sha512_half( + ledger_space_hex("paychan") + + address_to_hex(address) + + address_to_hex(dst_address) + + format(sequence, "x").zfill(8) # 4 bytes = 8 hex characters + ) + + +def hash_signer_list_id(address: str) -> str: + """Compute the hash of a SignerList ledger entry. + + Args: + address: The classic account address of the SignerList owner. + + Returns: + The computed hash of the signer list in uppercase hexadecimal. + """ + return sha512_half( + ledger_space_hex("signerList") + address_to_hex(address) + "00000000" + ) + + +def hash_trustline(address1: str, address2: str, currency: Union[str, bytes]) -> str: + """Compute the hash of a Trustline (RippleState) ledger entry. + + Args: + address1: One of the addresses in the trustline. + address2: The other address in the trustline. + currency: The currency code (3 characters) or currency hash. + + Returns: + The computed hash of the trustline in uppercase hexadecimal. + """ + # Convert addresses to hex + addr1_hex = address_to_hex(address1) + addr2_hex = address_to_hex(address2) + + # Ensure consistent ordering (lower address first) + if int(addr1_hex, 16) > int(addr2_hex, 16): + addr1_hex, addr2_hex = addr2_hex, addr1_hex + + # Handle currency formatting + if isinstance(currency, str) and len(currency) == 3: + # Standard 3-character currency code + currency_hex = "00" * 12 + currency.encode('ascii').hex().upper() + "00" * 5 + else: + # Assume it's already a hex string or bytes + if isinstance(currency, bytes): + currency_hex = currency.hex().upper() + else: + currency_hex = str(currency).upper() + + return sha512_half( + ledger_space_hex("rippleState") + addr1_hex + addr2_hex + currency_hex + ) + + +def hash_ticket(address: str, ticket_id: int) -> str: + """Compute the hash of a Ticket ledger entry. + + Args: + address: The address associated with the ticket. + ticket_id: The ticket identifier. + + Returns: + The computed hash of the ticket in uppercase hexadecimal. + """ + return sha512_half( + ledger_space_hex("ticket") + + address_to_hex(address) + + format(ticket_id, "x").zfill(8) # 4 bytes = 8 hex characters + ) + + +def hash_uri_token(issuer: str, uri: str) -> str: + """Compute the hash of a URIToken ledger entry. + + This is primarily used in Xahau networks but included for compatibility. + + Args: + issuer: The address of the issuer. + uri: The URI associated with the token. + + Returns: + The computed hash of the URI token in uppercase hexadecimal. + """ + return sha512_half( + ledger_space_hex("uriToken") + address_to_hex(issuer) + str_to_hex(uri) + ) + + +def hash_deposit_preauth(address: str, authorized_address: str) -> str: + """Compute the hash of a DepositPreauth ledger entry. + + Args: + address: The account that granted the authorization. + authorized_address: The account that was authorized. + + Returns: + The computed hash of the deposit preauth in uppercase hexadecimal. + """ + return sha512_half( + ledger_space_hex("depositPreauth") + + address_to_hex(address) + + address_to_hex(authorized_address) + ) + +# Aliases for compatibility with different naming conventions +hash_offer_id = hash_offer +hash_ripple_state = hash_trustline \ No newline at end of file From db02a586d29df301bf118f7b8f72169274d26d7a Mon Sep 17 00:00:00 2001 From: Tushar Pardhe Date: Thu, 4 Sep 2025 17:17:13 +0530 Subject: [PATCH 2/6] chore (846): updated changelog for ledger object hashing --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b832c0b1..410ba233d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [[Unreleased]] +### Added +- Added ledger object hashing utilities for computing hashes of various XRPL ledger objects +- Added `hash_account_root`, `hash_offer`, `hash_escrow`, `hash_payment_channel`, `hash_trustline`, `hash_signer_list_id`, `hash_check`, `hash_ticket`, `hash_deposit_preauth`, and `hash_uri_token` functions +- Added comprehensive test coverage for all hash utility functions +- These utilities are essential for Batch transactions where you need to know the hash of a ledger entry object before it's created on the ledger + ### Fixed - Removed snippets files from the xrpl-py code repository. Updated the README file to point to the correct location on XRPL.org. From 7bf1c6e60f0ee1782aa2f291d1761c1d78a65035 Mon Sep 17 00:00:00 2001 From: Tushar Pardhe Date: Thu, 4 Sep 2025 17:40:28 +0530 Subject: [PATCH 3/6] chore (846): enhance ledger object hashing utilities and improve test coverage --- CHANGELOG.md | 8 ++--- tests/unit/utils/test_hash_utils.py | 55 +++++++++++++++++------------ xrpl/utils/__init__.py | 2 -- xrpl/utils/hash_utils.py | 52 +++++++++++++-------------- 4 files changed, 61 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 410ba233d..10065c0d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [[Unreleased]] ### Added -- Added ledger object hashing utilities for computing hashes of various XRPL ledger objects -- Added `hash_account_root`, `hash_offer`, `hash_escrow`, `hash_payment_channel`, `hash_trustline`, `hash_signer_list_id`, `hash_check`, `hash_ticket`, `hash_deposit_preauth`, and `hash_uri_token` functions -- Added comprehensive test coverage for all hash utility functions -- These utilities are essential for Batch transactions where you need to know the hash of a ledger entry object before it's created on the ledger +- Ledger object hashing utilities for XRPL ledger objects. +- New functions: `hash_account_root`, `hash_offer` (alias: `hash_offer_id`), `hash_escrow`, `hash_payment_channel`, `hash_trustline` (alias: `hash_ripple_state`), `hash_signer_list_id`, `hash_check`, `hash_ticket`, `hash_deposit_preauth`. +- Comprehensive unit tests for all hash utilities. +- Useful for Batch transactions that require the ledger entry hash prior to creation. ### Fixed - Removed snippets files from the xrpl-py code repository. Updated the README file to point to the correct location on XRPL.org. diff --git a/tests/unit/utils/test_hash_utils.py b/tests/unit/utils/test_hash_utils.py index 42bffffa1..d92963cc3 100644 --- a/tests/unit/utils/test_hash_utils.py +++ b/tests/unit/utils/test_hash_utils.py @@ -17,7 +17,6 @@ hash_signer_list_id, hash_ticket, hash_trustline, - hash_uri_token, ledger_space_hex, sha512_half, ) @@ -186,17 +185,6 @@ def test_hash_deposit_preauth(self): self.assertTrue(result.isupper()) self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) - def test_hash_uri_token(self): - """Test URI token hash calculation.""" - # Use a valid XRPL address - issuer = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" - uri = "https://example.com/token" - result = hash_uri_token(issuer, uri) - # Should return a 64-character uppercase hex string - self.assertEqual(len(result), 64) - self.assertTrue(result.isupper()) - self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) - class TestEdgeCases(TestCase): """Test edge cases and error conditions.""" @@ -224,6 +212,30 @@ def test_large_sequence_number(self): self.assertEqual(len(result), 64) self.assertTrue(result.isupper()) + def test_invalid_sequence_numbers(self): + """Test hash functions with invalid sequence numbers.""" + address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + + # Test negative sequence number + with self.assertRaises(ValueError) as context: + hash_offer(address, -1) + self.assertIn("Sequence must be in range [0, 2^32 - 1]", str(context.exception)) + + # Test sequence number too large (> 2^32 - 1) + with self.assertRaises(ValueError) as context: + hash_offer(address, 2**32) + self.assertIn("Sequence must be in range [0, 2^32 - 1]", str(context.exception)) + + # Test with other hash functions that use sequences + with self.assertRaises(ValueError): + hash_escrow(address, -1) + + with self.assertRaises(ValueError): + hash_check(address, 2**32) + + with self.assertRaises(ValueError): + hash_ticket(address, -5) + def test_invalid_address_in_hash_functions(self): """Test hash functions with invalid addresses.""" invalid_address = "invalid_address" @@ -237,16 +249,6 @@ def test_invalid_address_in_hash_functions(self): with self.assertRaises((XRPLAddressCodecException, ValueError)): hash_escrow(invalid_address, 123) - def test_empty_uri(self): - """Test URI token hash with empty URI.""" - # Use a valid XRPL address - issuer = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" - uri = "" - result = hash_uri_token(issuer, uri) - # Should still return a valid hash - self.assertEqual(len(result), 64) - self.assertTrue(result.isupper()) - def test_currency_formats(self): """Test different currency formats in trustline hash.""" address1 = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" @@ -261,6 +263,13 @@ def test_currency_formats(self): self.assertEqual(len(result_eur), 64) self.assertNotEqual(result_usd, result_eur) + # Test case-sensitivity for 3-character currencies (should be different) + result_usd_lower = hash_trustline(address1, address2, "usd") + result_usd_mixed = hash_trustline(address1, address2, "UsD") + self.assertNotEqual(result_usd, result_usd_lower) + self.assertNotEqual(result_usd, result_usd_mixed) + self.assertNotEqual(result_usd_lower, result_usd_mixed) + # Test currency as bytes (covers the bytes case) currency_bytes = b"USD" result_bytes = hash_trustline(address1, address2, currency_bytes) @@ -279,7 +288,7 @@ def test_ledger_spaces_completeness(self): "account", "dirNode", "generatorMap", "rippleState", "offer", "ownerDir", "bookDir", "contract", "skipList", "escrow", "amendment", "feeSettings", "ticket", "signerList", "paychan", - "check", "uriToken", "depositPreauth" + "check", "depositPreauth" ] for space in expected_spaces: diff --git a/xrpl/utils/__init__.py b/xrpl/utils/__init__.py index 2ed65623b..f3719023e 100644 --- a/xrpl/utils/__init__.py +++ b/xrpl/utils/__init__.py @@ -14,7 +14,6 @@ hash_signer_list_id, hash_ticket, hash_trustline, - hash_uri_token, ) from xrpl.utils.parse_nftoken_id import parse_nftoken_id from xrpl.utils.str_conversions import hex_to_str, str_to_hex @@ -61,5 +60,4 @@ "hash_signer_list_id", "hash_ticket", "hash_trustline", - "hash_uri_token", ] diff --git a/xrpl/utils/hash_utils.py b/xrpl/utils/hash_utils.py index 39ab303d1..31e2da0b0 100644 --- a/xrpl/utils/hash_utils.py +++ b/xrpl/utils/hash_utils.py @@ -14,11 +14,10 @@ from typing import Union from xrpl.core import addresscodec -from xrpl.utils.str_conversions import str_to_hex # Constants -HEX = 16 BYTE_LENGTH = 4 # 4 bytes for sequence numbers +SEQ_HEX_LEN = BYTE_LENGTH * 2 # 8 hex chars # Ledger space dictionary mapping ledger object types to their namespace identifiers LEDGER_SPACES = { @@ -38,7 +37,6 @@ "signerList": "S", "paychan": "x", "check": "C", - "uriToken": "U", "depositPreauth": "p", } @@ -56,6 +54,23 @@ def sha512_half(data: str) -> str: return hash_obj.hexdigest()[:64].upper() +def _u32_hex(n: int) -> str: + """Convert a 32-bit unsigned integer to 8-character hex string. + + Args: + n: Integer to convert (must be in range [0, 2^32 - 1]). + + Returns: + 8-character lowercase hex string with zero padding. + + Raises: + ValueError: If n is outside the valid 32-bit unsigned range. + """ + if n < 0 or n > 0xFFFFFFFF: + raise ValueError("Sequence must be in range [0, 2^32 - 1].") + return format(n, "x").zfill(SEQ_HEX_LEN) + + def address_to_hex(address: str) -> str: """Convert an XRPL address to its hexadecimal representation. @@ -66,7 +81,7 @@ def address_to_hex(address: str) -> str: The hexadecimal representation of the address. Raises: - XRPLAddressCodecException: If the address is invalid. + XRPLAddressCodecException or ValueError: If the address is invalid. """ return addresscodec.decode_classic_address(address).hex().upper() @@ -111,7 +126,7 @@ def hash_offer(address: str, sequence: int) -> str: return sha512_half( ledger_space_hex("offer") + address_to_hex(address) - + format(sequence, "x").zfill(8) # 4 bytes = 8 hex characters + + _u32_hex(sequence) ) @@ -128,7 +143,7 @@ def hash_check(address: str, sequence: int) -> str: return sha512_half( ledger_space_hex("check") + address_to_hex(address) - + format(sequence, "x").zfill(8) # 4 bytes = 8 hex characters + + _u32_hex(sequence) ) @@ -145,7 +160,7 @@ def hash_escrow(address: str, sequence: int) -> str: return sha512_half( ledger_space_hex("escrow") + address_to_hex(address) - + format(sequence, "x").zfill(8) # 4 bytes = 8 hex characters + + _u32_hex(sequence) ) @@ -164,7 +179,7 @@ def hash_payment_channel(address: str, dst_address: str, sequence: int) -> str: ledger_space_hex("paychan") + address_to_hex(address) + address_to_hex(dst_address) - + format(sequence, "x").zfill(8) # 4 bytes = 8 hex characters + + _u32_hex(sequence) ) @@ -178,7 +193,7 @@ def hash_signer_list_id(address: str) -> str: The computed hash of the signer list in uppercase hexadecimal. """ return sha512_half( - ledger_space_hex("signerList") + address_to_hex(address) + "00000000" + ledger_space_hex("signerList") + address_to_hex(address) + _u32_hex(0) ) @@ -230,24 +245,7 @@ def hash_ticket(address: str, ticket_id: int) -> str: return sha512_half( ledger_space_hex("ticket") + address_to_hex(address) - + format(ticket_id, "x").zfill(8) # 4 bytes = 8 hex characters - ) - - -def hash_uri_token(issuer: str, uri: str) -> str: - """Compute the hash of a URIToken ledger entry. - - This is primarily used in Xahau networks but included for compatibility. - - Args: - issuer: The address of the issuer. - uri: The URI associated with the token. - - Returns: - The computed hash of the URI token in uppercase hexadecimal. - """ - return sha512_half( - ledger_space_hex("uriToken") + address_to_hex(issuer) + str_to_hex(uri) + + _u32_hex(ticket_id) ) From 95368d2f5322057204b4bdb3aad3d30896c2641f Mon Sep 17 00:00:00 2001 From: Tushar Pardhe Date: Thu, 4 Sep 2025 17:58:42 +0530 Subject: [PATCH 4/6] test (846): enhance hash utility tests and improve error handling for currency formats --- tests/unit/utils/test_hash_utils.py | 57 +++++++++++++++++++++++++---- xrpl/utils/hash_utils.py | 21 ++++++----- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/tests/unit/utils/test_hash_utils.py b/tests/unit/utils/test_hash_utils.py index d92963cc3..c4517d6d8 100644 --- a/tests/unit/utils/test_hash_utils.py +++ b/tests/unit/utils/test_hash_utils.py @@ -27,9 +27,12 @@ class TestHashUtilsBasic(TestCase): def test_sha512_half(self): """Test SHA512 half hash function.""" - # Test with known input/output + # Test with known input/output for "Hello World" test_data = "48656C6C6F20576F726C64" # "Hello World" in hex result = sha512_half(test_data) + # Known SHA-512 half result for "Hello World" + expected = "2C74FD17EDAFD80E8447B0D46741EE243B7EB74DD2149A0AB1B9246FB30382F2" + self.assertEqual(result, expected) # Should be 64 characters (32 bytes) uppercase hex self.assertEqual(len(result), 64) self.assertTrue(result.isupper()) @@ -149,8 +152,10 @@ def test_hash_trustline_alias(self): address1 = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" address2 = "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY" currency = "USD" - self.assertEqual(hash_trustline(address1, address2, currency), - hash_ripple_state(address1, address2, currency)) + self.assertEqual( + hash_trustline(address1, address2, currency), + hash_ripple_state(address1, address2, currency), + ) def test_hash_check(self): """Test check hash calculation.""" @@ -184,6 +189,11 @@ def test_hash_deposit_preauth(self): self.assertEqual(len(result), 64) self.assertTrue(result.isupper()) self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) + + # Test directionality - swapping addresses should give different hash + result_swapped = hash_deposit_preauth(authorized_address, address) + self.assertEqual(len(result_swapped), 64) + self.assertNotEqual(result, result_swapped) class TestEdgeCases(TestCase): @@ -219,12 +229,12 @@ def test_invalid_sequence_numbers(self): # Test negative sequence number with self.assertRaises(ValueError) as context: hash_offer(address, -1) - self.assertIn("Sequence must be in range [0, 2^32 - 1]", str(context.exception)) + self.assertIn("u32 sequence out of range", str(context.exception)) # Test sequence number too large (> 2^32 - 1) with self.assertRaises(ValueError) as context: hash_offer(address, 2**32) - self.assertIn("Sequence must be in range [0, 2^32 - 1]", str(context.exception)) + self.assertIn("u32 sequence out of range", str(context.exception)) # Test with other hash functions that use sequences with self.assertRaises(ValueError): @@ -270,8 +280,8 @@ def test_currency_formats(self): self.assertNotEqual(result_usd, result_usd_mixed) self.assertNotEqual(result_usd_lower, result_usd_mixed) - # Test currency as bytes (covers the bytes case) - currency_bytes = b"USD" + # Test currency as 20-byte bytes (proper format) + currency_bytes = bytes.fromhex("0000000000000000555344000000000000000000") # USD as 20 bytes result_bytes = hash_trustline(address1, address2, currency_bytes) self.assertEqual(len(result_bytes), 64) self.assertTrue(result_bytes.isupper()) @@ -282,6 +292,39 @@ def test_currency_formats(self): self.assertEqual(len(result_hex), 64) self.assertTrue(result_hex.isupper()) + def test_invalid_currency_formats(self): + """Test invalid currency formats in trustline hash.""" + address1 = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + address2 = "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY" + + # Test bytes with wrong length - should raise ValueError + short_bytes = b"US" # Only 2 bytes instead of 20 + with self.assertRaises(ValueError) as context: + hash_trustline(address1, address2, short_bytes) + self.assertIn("Currency bytes must be exactly 20 bytes", str(context.exception)) + + long_bytes = b"USD" * 10 # Too many bytes + with self.assertRaises(ValueError) as context: + hash_trustline(address1, address2, long_bytes) + self.assertIn("Currency bytes must be exactly 20 bytes", str(context.exception)) + + # Test hex string with wrong length - should raise ValueError + short_hex = "123456" # Only 6 hex chars instead of 40 + with self.assertRaises(ValueError) as context: + hash_trustline(address1, address2, short_hex) + self.assertIn("Currency hex must be exactly 40 hex characters", str(context.exception)) + + long_hex = "0123456789ABCDEF" * 3 # Too long + with self.assertRaises(ValueError) as context: + hash_trustline(address1, address2, long_hex) + self.assertIn("Currency hex must be exactly 40 hex characters", str(context.exception)) + + # Test hex string with invalid characters (non-hex) - should raise ValueError + invalid_hex = "GHIJKLMNOPQRSTUVWXYZ0123456789ABCDEF01" # Contains non-hex chars + with self.assertRaises(ValueError) as context: + hash_trustline(address1, address2, invalid_hex) + self.assertIn("Currency hex must be exactly 40 hex characters", str(context.exception)) + def test_ledger_spaces_completeness(self): """Test that all expected ledger spaces are defined.""" expected_spaces = [ diff --git a/xrpl/utils/hash_utils.py b/xrpl/utils/hash_utils.py index 31e2da0b0..b84170ca6 100644 --- a/xrpl/utils/hash_utils.py +++ b/xrpl/utils/hash_utils.py @@ -11,6 +11,7 @@ """ import hashlib +import re from typing import Union from xrpl.core import addresscodec @@ -67,7 +68,7 @@ def _u32_hex(n: int) -> str: ValueError: If n is outside the valid 32-bit unsigned range. """ if n < 0 or n > 0xFFFFFFFF: - raise ValueError("Sequence must be in range [0, 2^32 - 1].") + raise ValueError("u32 sequence out of range (0..=2^32-1)") return format(n, "x").zfill(SEQ_HEX_LEN) @@ -216,16 +217,18 @@ def hash_trustline(address1: str, address2: str, currency: Union[str, bytes]) -> if int(addr1_hex, 16) > int(addr2_hex, 16): addr1_hex, addr2_hex = addr2_hex, addr1_hex - # Handle currency formatting + # Handle currency formatting (must be 20 bytes) if isinstance(currency, str) and len(currency) == 3: - # Standard 3-character currency code - currency_hex = "00" * 12 + currency.encode('ascii').hex().upper() + "00" * 5 + currency_hex = ("00" * 12) + currency.encode("ascii").hex().upper() + ("00" * 5) + elif isinstance(currency, bytes): + if len(currency) != 20: + raise ValueError("Currency bytes must be exactly 20 bytes.") + currency_hex = currency.hex().upper() else: - # Assume it's already a hex string or bytes - if isinstance(currency, bytes): - currency_hex = currency.hex().upper() - else: - currency_hex = str(currency).upper() + hex_str = str(currency) + if not re.fullmatch(r"[0-9A-Fa-f]{40}", hex_str): + raise ValueError("Currency hex must be exactly 40 hex characters.") + currency_hex = hex_str.upper() return sha512_half( ledger_space_hex("rippleState") + addr1_hex + addr2_hex + currency_hex From 675ee6d6b365e04d2c13126897abb7902b072a46 Mon Sep 17 00:00:00 2001 From: Tushar Pardhe Date: Thu, 4 Sep 2025 18:40:52 +0530 Subject: [PATCH 5/6] feat (846): enhance hash utilities with additional input handling and error checks --- tests/unit/utils/test_hash_utils.py | 78 +++++++++++++++++++---------- xrpl/utils/hash_utils.py | 26 +++++++--- 2 files changed, 72 insertions(+), 32 deletions(-) diff --git a/tests/unit/utils/test_hash_utils.py b/tests/unit/utils/test_hash_utils.py index c4517d6d8..30e6b54c3 100644 --- a/tests/unit/utils/test_hash_utils.py +++ b/tests/unit/utils/test_hash_utils.py @@ -37,6 +37,21 @@ def test_sha512_half(self): self.assertEqual(len(result), 64) self.assertTrue(result.isupper()) self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) + + # Test with bytes input + result_bytes = sha512_half(b"Hello World") + self.assertEqual(len(result_bytes), 64) + self.assertTrue(result_bytes.isupper()) + + # Invalid hex should raise + with self.assertRaises(ValueError): + sha512_half("ZZ") # non-hex + with self.assertRaises(ValueError): + sha512_half("A") # odd length + + # Invalid type should raise + with self.assertRaises(TypeError): + sha512_half(123) def test_address_to_hex(self): """Test address to hex conversion.""" @@ -189,11 +204,17 @@ def test_hash_deposit_preauth(self): self.assertEqual(len(result), 64) self.assertTrue(result.isupper()) self.assertTrue(all(c in "0123456789ABCDEF" for c in result)) + + def test_hash_deposit_preauth_directionality(self): + """Test that deposit preauth hash is directional.""" + # Use valid XRPL addresses + address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + authorized_address = "rDx69ebzbowuqztksVDmZXjizTd12BVr4x" - # Test directionality - swapping addresses should give different hash - result_swapped = hash_deposit_preauth(authorized_address, address) - self.assertEqual(len(result_swapped), 64) - self.assertNotEqual(result, result_swapped) + # Swapping addresses should produce different hashes + result1 = hash_deposit_preauth(address, authorized_address) + result2 = hash_deposit_preauth(authorized_address, address) + self.assertNotEqual(result1, result2) class TestEdgeCases(TestCase): @@ -259,6 +280,24 @@ def test_invalid_address_in_hash_functions(self): with self.assertRaises((XRPLAddressCodecException, ValueError)): hash_escrow(invalid_address, 123) + with self.assertRaises((XRPLAddressCodecException, ValueError)): + hash_payment_channel(invalid_address, "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", 1) + + with self.assertRaises((XRPLAddressCodecException, ValueError)): + hash_payment_channel("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", invalid_address, 1) + + with self.assertRaises((XRPLAddressCodecException, ValueError)): + hash_deposit_preauth(invalid_address, "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh") + + with self.assertRaises((XRPLAddressCodecException, ValueError)): + hash_deposit_preauth("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", invalid_address) + + with self.assertRaises((XRPLAddressCodecException, ValueError)): + hash_check(invalid_address, 1) + + with self.assertRaises((XRPLAddressCodecException, ValueError)): + hash_ticket(invalid_address, 1) + def test_currency_formats(self): """Test different currency formats in trustline hash.""" address1 = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" @@ -280,14 +319,14 @@ def test_currency_formats(self): self.assertNotEqual(result_usd, result_usd_mixed) self.assertNotEqual(result_usd_lower, result_usd_mixed) - # Test currency as 20-byte bytes (proper format) - currency_bytes = bytes.fromhex("0000000000000000555344000000000000000000") # USD as 20 bytes + # Test currency as bytes (covers the bytes case) - must be exactly 20 bytes + currency_bytes = b"USD" + b"\x00" * 17 # 20 bytes total result_bytes = hash_trustline(address1, address2, currency_bytes) self.assertEqual(len(result_bytes), 64) self.assertTrue(result_bytes.isupper()) # Test currency as hex string (covers the else case for non-3-char strings) - currency_hex = "0000000000000000555344000000000000000000" # USD as hex + currency_hex = "0000000000000000555344000000000000000000" # USD as hex, 40 chars result_hex = hash_trustline(address1, address2, currency_hex) self.assertEqual(len(result_hex), 64) self.assertTrue(result_hex.isupper()) @@ -297,32 +336,19 @@ def test_invalid_currency_formats(self): address1 = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" address2 = "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY" - # Test bytes with wrong length - should raise ValueError - short_bytes = b"US" # Only 2 bytes instead of 20 - with self.assertRaises(ValueError) as context: - hash_trustline(address1, address2, short_bytes) - self.assertIn("Currency bytes must be exactly 20 bytes", str(context.exception)) - - long_bytes = b"USD" * 10 # Too many bytes + # Test invalid bytes length with self.assertRaises(ValueError) as context: - hash_trustline(address1, address2, long_bytes) + hash_trustline(address1, address2, b"USD") # Only 3 bytes, need 20 self.assertIn("Currency bytes must be exactly 20 bytes", str(context.exception)) - # Test hex string with wrong length - should raise ValueError - short_hex = "123456" # Only 6 hex chars instead of 40 - with self.assertRaises(ValueError) as context: - hash_trustline(address1, address2, short_hex) - self.assertIn("Currency hex must be exactly 40 hex characters", str(context.exception)) - - long_hex = "0123456789ABCDEF" * 3 # Too long + # Test invalid hex string length with self.assertRaises(ValueError) as context: - hash_trustline(address1, address2, long_hex) + hash_trustline(address1, address2, "INVALID") # Not 40 hex chars self.assertIn("Currency hex must be exactly 40 hex characters", str(context.exception)) - # Test hex string with invalid characters (non-hex) - should raise ValueError - invalid_hex = "GHIJKLMNOPQRSTUVWXYZ0123456789ABCDEF01" # Contains non-hex chars + # Test invalid hex characters with self.assertRaises(ValueError) as context: - hash_trustline(address1, address2, invalid_hex) + hash_trustline(address1, address2, "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ") # 40 chars but invalid hex self.assertIn("Currency hex must be exactly 40 hex characters", str(context.exception)) def test_ledger_spaces_completeness(self): diff --git a/xrpl/utils/hash_utils.py b/xrpl/utils/hash_utils.py index b84170ca6..e2f7940da 100644 --- a/xrpl/utils/hash_utils.py +++ b/xrpl/utils/hash_utils.py @@ -41,17 +41,31 @@ "depositPreauth": "p", } +# Precompute hex (4 chars) once at import time +LEDGER_SPACES_HEX = {k: format(ord(v), "x").zfill(4) for k, v in LEDGER_SPACES.items()} -def sha512_half(data: str) -> str: + +def sha512_half(data: Union[str, bytes, bytearray, memoryview]) -> str: """Compute the SHA-512 hash and then take the first half of the result. Args: - data: The input data in hexadecimal format. + data: The input data in hexadecimal format (str) or bytes-like object. Returns: The first half of the SHA-512 hash in uppercase hexadecimal. + + Raises: + TypeError: If data is not str or bytes-like. + ValueError: If hex string is malformed. """ - hash_obj = hashlib.sha512(bytes.fromhex(data)) + if isinstance(data, (bytes, bytearray, memoryview)): + buf = bytes(data) + elif isinstance(data, str): + buf = bytes.fromhex(data) + else: + raise TypeError("data must be hex str or bytes-like") + + hash_obj = hashlib.sha512(buf) return hash_obj.hexdigest()[:64].upper() @@ -68,7 +82,7 @@ def _u32_hex(n: int) -> str: ValueError: If n is outside the valid 32-bit unsigned range. """ if n < 0 or n > 0xFFFFFFFF: - raise ValueError("u32 sequence out of range (0..=2^32-1)") + raise ValueError("u32 sequence out of range (0..=2^32-1)") # noqa: TRY003 return format(n, "x").zfill(SEQ_HEX_LEN) @@ -99,7 +113,7 @@ def ledger_space_hex(name: str) -> str: Raises: KeyError: If the ledger space name is not recognized. """ - return format(ord(LEDGER_SPACES[name]), "x").zfill(4) + return LEDGER_SPACES_HEX[name] def hash_account_root(address: str) -> str: @@ -214,7 +228,7 @@ def hash_trustline(address1: str, address2: str, currency: Union[str, bytes]) -> addr2_hex = address_to_hex(address2) # Ensure consistent ordering (lower address first) - if int(addr1_hex, 16) > int(addr2_hex, 16): + if addr1_hex > addr2_hex: addr1_hex, addr2_hex = addr2_hex, addr1_hex # Handle currency formatting (must be 20 bytes) From e9e2189aed0868ad8146be23501862c021b0e21e Mon Sep 17 00:00:00 2001 From: Tushar Pardhe Date: Thu, 4 Sep 2025 19:09:40 +0530 Subject: [PATCH 6/6] refactor(hash_utils): rename ticket_id to ticket_sequence and update related comments for clarity --- tests/unit/utils/test_hash_utils.py | 12 ++++++------ xrpl/utils/hash_utils.py | 17 +++++++++-------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/unit/utils/test_hash_utils.py b/tests/unit/utils/test_hash_utils.py index 30e6b54c3..1d44b4b76 100644 --- a/tests/unit/utils/test_hash_utils.py +++ b/tests/unit/utils/test_hash_utils.py @@ -187,8 +187,8 @@ def test_hash_ticket(self): """Test ticket hash calculation.""" # Use a valid XRPL address address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" - ticket_id = 25 - result = hash_ticket(address, ticket_id) + ticket_sequence = 25 + result = hash_ticket(address, ticket_sequence) # Should return a 64-character uppercase hex string self.assertEqual(len(result), 64) self.assertTrue(result.isupper()) @@ -312,12 +312,12 @@ def test_currency_formats(self): self.assertEqual(len(result_eur), 64) self.assertNotEqual(result_usd, result_eur) - # Test case-sensitivity for 3-character currencies (should be different) + # Test case-insensitivity for 3-character currencies (should be same after canonicalization) result_usd_lower = hash_trustline(address1, address2, "usd") result_usd_mixed = hash_trustline(address1, address2, "UsD") - self.assertNotEqual(result_usd, result_usd_lower) - self.assertNotEqual(result_usd, result_usd_mixed) - self.assertNotEqual(result_usd_lower, result_usd_mixed) + self.assertEqual(result_usd, result_usd_lower) + self.assertEqual(result_usd, result_usd_mixed) + self.assertEqual(result_usd_lower, result_usd_mixed) # Test currency as bytes (covers the bytes case) - must be exactly 20 bytes currency_bytes = b"USD" + b"\x00" * 17 # 20 bytes total diff --git a/xrpl/utils/hash_utils.py b/xrpl/utils/hash_utils.py index e2f7940da..1b681ec4c 100644 --- a/xrpl/utils/hash_utils.py +++ b/xrpl/utils/hash_utils.py @@ -66,7 +66,7 @@ def sha512_half(data: Union[str, bytes, bytearray, memoryview]) -> str: raise TypeError("data must be hex str or bytes-like") hash_obj = hashlib.sha512(buf) - return hash_obj.hexdigest()[:64].upper() + return hash_obj.digest()[:32].hex().upper() def _u32_hex(n: int) -> str: @@ -83,7 +83,7 @@ def _u32_hex(n: int) -> str: """ if n < 0 or n > 0xFFFFFFFF: raise ValueError("u32 sequence out of range (0..=2^32-1)") # noqa: TRY003 - return format(n, "x").zfill(SEQ_HEX_LEN) + return f"{n:0{SEQ_HEX_LEN}X}" def address_to_hex(address: str) -> str: @@ -233,15 +233,16 @@ def hash_trustline(address1: str, address2: str, currency: Union[str, bytes]) -> # Handle currency formatting (must be 20 bytes) if isinstance(currency, str) and len(currency) == 3: - currency_hex = ("00" * 12) + currency.encode("ascii").hex().upper() + ("00" * 5) + # Canonicalize 3-char currency to uppercase + currency_hex = ("00" * 12) + currency.upper().encode("ascii").hex().upper() + ("00" * 5) elif isinstance(currency, bytes): if len(currency) != 20: - raise ValueError("Currency bytes must be exactly 20 bytes.") + raise ValueError("Currency bytes must be exactly 20 bytes.") # noqa: TRY003 currency_hex = currency.hex().upper() else: hex_str = str(currency) if not re.fullmatch(r"[0-9A-Fa-f]{40}", hex_str): - raise ValueError("Currency hex must be exactly 40 hex characters.") + raise ValueError("Currency hex must be exactly 40 hex characters.") # noqa: TRY003 currency_hex = hex_str.upper() return sha512_half( @@ -249,12 +250,12 @@ def hash_trustline(address1: str, address2: str, currency: Union[str, bytes]) -> ) -def hash_ticket(address: str, ticket_id: int) -> str: +def hash_ticket(address: str, ticket_sequence: int) -> str: """Compute the hash of a Ticket ledger entry. Args: address: The address associated with the ticket. - ticket_id: The ticket identifier. + ticket_sequence: The TicketSequence (32-bit) used to derive the Ticket index. Returns: The computed hash of the ticket in uppercase hexadecimal. @@ -262,7 +263,7 @@ def hash_ticket(address: str, ticket_id: int) -> str: return sha512_half( ledger_space_hex("ticket") + address_to_hex(address) - + _u32_hex(ticket_id) + + _u32_hex(ticket_sequence) )