diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b832c0b1..10065c0d8 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 +- 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 new file mode 100644 index 000000000..1d44b4b76 --- /dev/null +++ b/tests/unit/utils/test_hash_utils.py @@ -0,0 +1,401 @@ +"""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, + 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 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()) + 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.""" + 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_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()) + 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_deposit_preauth_directionality(self): + """Test that deposit preauth hash is directional.""" + # Use valid XRPL addresses + address = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + authorized_address = "rDx69ebzbowuqztksVDmZXjizTd12BVr4x" + + # 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): + """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_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("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("u32 sequence out of range", 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" + + 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) + + 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" + 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 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.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 + 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, 40 chars + result_hex = hash_trustline(address1, address2, currency_hex) + 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 invalid bytes length + with self.assertRaises(ValueError) as context: + hash_trustline(address1, address2, b"USD") # Only 3 bytes, need 20 + self.assertIn("Currency bytes must be exactly 20 bytes", str(context.exception)) + + # Test invalid hex string length + with self.assertRaises(ValueError) as context: + hash_trustline(address1, address2, "INVALID") # Not 40 hex chars + self.assertIn("Currency hex must be exactly 40 hex characters", str(context.exception)) + + # Test invalid hex characters + with self.assertRaises(ValueError) as context: + 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): + """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", "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..f3719023e 100644 --- a/xrpl/utils/__init__.py +++ b/xrpl/utils/__init__.py @@ -2,6 +2,19 @@ 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, +) 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 +48,16 @@ "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", ] diff --git a/xrpl/utils/hash_utils.py b/xrpl/utils/hash_utils.py new file mode 100644 index 000000000..1b681ec4c --- /dev/null +++ b/xrpl/utils/hash_utils.py @@ -0,0 +1,288 @@ +"""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 +import re +from typing import Union + +from xrpl.core import addresscodec + +# Constants +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 = { + "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", + "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: 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 (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. + """ + 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.digest()[:32].hex().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("u32 sequence out of range (0..=2^32-1)") # noqa: TRY003 + return f"{n:0{SEQ_HEX_LEN}X}" + + +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 or ValueError: 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 LEDGER_SPACES_HEX[name] + + +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) + + _u32_hex(sequence) + ) + + +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) + + _u32_hex(sequence) + ) + + +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) + + _u32_hex(sequence) + ) + + +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) + + _u32_hex(sequence) + ) + + +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) + _u32_hex(0) + ) + + +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 addr1_hex > addr2_hex: + addr1_hex, addr2_hex = addr2_hex, addr1_hex + + # Handle currency formatting (must be 20 bytes) + if isinstance(currency, str) and len(currency) == 3: + # 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.") # 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.") # noqa: TRY003 + currency_hex = hex_str.upper() + + return sha512_half( + ledger_space_hex("rippleState") + addr1_hex + addr2_hex + currency_hex + ) + + +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_sequence: The TicketSequence (32-bit) used to derive the Ticket index. + + Returns: + The computed hash of the ticket in uppercase hexadecimal. + """ + return sha512_half( + ledger_space_hex("ticket") + + address_to_hex(address) + + _u32_hex(ticket_sequence) + ) + + +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