diff --git a/CHANGELOG.md b/CHANGELOG.md index f1077bcd4..d255d15f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - Format `tests/unit/custom_fee_test.py` with black for code style consistency. (#1525) ### Added +- Added support for include_children to the TransactionRecordQuery class - Added new members to the mentor roster. (#1693) - Added support for the `includeDuplicates` flag in `TransactionRecordQuery` and `duplicates` field in `TransactionRecord` (#1635) - Added logging in bot-gfi-assign-on-comment.js to prevent silent skips. (`#1668`) @@ -250,6 +251,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - Enhance TopicInfo `__str__` method and tests with additional coverage, and update the format_key function in `key_format.py` to handle objects with a \_to_proto method. - Update changelog workflow to trigger automatically on pull requests instead of manual dispatch (#1567) - Formatted key-related unit test files (`key_utils_test.py`, `test_key_format.py`, `test_key_list.py`) using the black formatter +- chore: update maintainer guidelines link in MAINTAINERS.md (#1605) - Add return type hint to `ContractId.__str__`. (#1654) - chore: update maintainer guidelines link in MAINTAINERS.md (#1605) - chore: update merge conflict bot message with web editor tips (#1592) diff --git a/examples/transaction/transaction_record_with_children.py b/examples/transaction/transaction_record_with_children.py new file mode 100644 index 000000000..7f1da2e43 --- /dev/null +++ b/examples/transaction/transaction_record_with_children.py @@ -0,0 +1,33 @@ +import os +from hiero_sdk_python.client import Client +from hiero_sdk_python.query.transaction_record_query import TransactionRecordQuery + +def main(): + # Setup client + client = Client.for_testnet() + # ... (add operator credentials) ... + + # 1. Execute a transaction that is likely to have child records + # (e.g., a complex Smart Contract call) + print("Executing transaction...") + + # 2. Query the record and explicitly request children + tx_id = "..." # your transaction id + + query = ( + TransactionRecordQuery() + .set_transaction_id(tx_id) + .set_include_children(True) # The new feature! + ) + + record = query.execute(client) + + # 3. Demonstrate accessing the children + print(f"Parent Transaction ID: {record.transaction_id}") + print(f"Number of child records found: {len(record.children)}") + + for i, child in enumerate(record.children): + print(f"Child {i+1} Status: {child.receipt.status}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/hiero_sdk_python/query/transaction_record_query.py b/src/hiero_sdk_python/query/transaction_record_query.py index 0f1d0cf09..ea2117edb 100644 --- a/src/hiero_sdk_python/query/transaction_record_query.py +++ b/src/hiero_sdk_python/query/transaction_record_query.py @@ -1,8 +1,10 @@ +from typing import Optional, Any, Union, List from typing import Optional, Any, Union from hiero_sdk_python.hapi.services import ( query_header_pb2, transaction_get_record_pb2, query_pb2, +) transaction_record_pb2, ) from hiero_sdk_python.client.client import Client @@ -25,12 +27,20 @@ class TransactionRecordQuery(Query): def __init__( self, transaction_id: Optional[TransactionId] = None, + include_children: bool = False, + ): include_duplicates: bool = False, ) -> None: """ Initializes the TransactionRecordQuery with the provided transaction ID. """ super().__init__() + self.transaction_id: Optional[TransactionId] = transaction_id + self.include_children = include_children + + def set_transaction_id(self, transaction_id: TransactionId): + """ + Sets the transaction ID for the query. if not isinstance(include_duplicates, bool): raise TypeError( f"include_duplicates must be a bool (True or False), got {type(include_duplicates).__name__}" @@ -85,6 +95,10 @@ def set_transaction_id( self.transaction_id = transaction_id return self + def set_include_children(self, include_children): + self.include_children = include_children + return self + def _make_request(self): """ Constructs the protobuf request for the transaction record query. @@ -100,6 +114,37 @@ def _make_request(self): AttributeError: If the Query protobuf structure is invalid. Exception: If any other error occurs during request construction. """ + try: + if not self.transaction_id: + raise ValueError( + "Transaction ID must be set before making the request." + ) + + query_header = self._make_request_header() + transaction_get_record = ( + transaction_get_record_pb2.TransactionGetRecordQuery() + ) + transaction_get_record.header.CopyFrom(query_header) + + transaction_get_record.transactionID.CopyFrom( + self.transaction_id._to_proto() + ) + transaction_get_record.include_child_records = self.include_children + query = query_pb2.Query() + if not hasattr(query, "transactionGetRecord"): + raise AttributeError( + "Query object has no attribute 'transactionGetRecord'" + ) + query.transactionGetRecord.CopyFrom(transaction_get_record) + + return query + except Exception as e: + print(f"Exception in _make_request: {e}") + raise + + def _get_method(self, channel: _Channel) -> _Method: + """ + Returns the appropriate gRPC method for the transaction receipt query. if self.transaction_id is None: raise ValueError("Transaction ID must be set before making the request.") @@ -189,6 +234,7 @@ def _should_retry(self, response: Any) -> _ExecutionState: == query_header_pb2.ResponseType.COST_ANSWER ): return _ExecutionState.FINISHED + pass elif ( status in retryable_statuses or status == ResponseCode.PLATFORM_TRANSACTION_NOT_CREATED @@ -236,6 +282,24 @@ def _map_status_error( return PrecheckError(status) receipt = response.transactionGetRecord.transactionRecord.receipt + + return ReceiptStatusError( + status, + self.transaction_id, + TransactionReceipt._from_proto(receipt, self.transaction_id), + ) + + def _map_record_list( + self, + proto_records: List[transaction_get_record_pb2.TransactionGetRecordResponse], + ) -> List[TransactionRecord]: + records: List[TransactionRecord] = [] + for record in proto_records: + records.append(TransactionRecord._from_proto(record, self.transaction_id)) + + return records + + def execute(self, client): return ReceiptStatusError(status, self.transaction_id, TransactionReceipt._from_proto(receipt, self.transaction_id)) @@ -261,6 +325,15 @@ def execute(self, client: Client, timeout: Optional[Union[int, float]] = None): ReceiptStatusError: If the transaction record contains an error status """ self._before_execute(client) + response = self._execute(client) + if not response.HasField("transactionGetRecord"): + raise AttributeError("Response does not contain 'transactionGetRecord'") + record_response = response.transactionGetRecord + children = self._map_record_list(record_response.child_transaction_records) + return TransactionRecord._from_proto( + response.transactionGetRecord.transactionRecord, + self.transaction_id, + children=children, response = self._execute(client, timeout) primary_proto = response.transactionGetRecord.transactionRecord if self.include_duplicates: diff --git a/src/hiero_sdk_python/transaction/transaction_record.py b/src/hiero_sdk_python/transaction/transaction_record.py index 60d1ce478..4f64a55ad 100644 --- a/src/hiero_sdk_python/transaction/transaction_record.py +++ b/src/hiero_sdk_python/transaction/transaction_record.py @@ -18,7 +18,7 @@ from collections import defaultdict from dataclasses import dataclass, field -from typing import Optional +from typing import Optional, List from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.contract.contract_function_result import ContractFunctionResult @@ -36,7 +36,7 @@ class TransactionRecord: Represents a record of a completed transaction on the Hiero network. This class combines detailed information about the a transaction including the transaction ID, receipt, token and NFT transfers, fees & other - metadata such as pseudo-random number generation(PRNG) results and + metadata such as pseudo-random number generation(PRNG) results and pending airdrop records. Attributes: @@ -46,16 +46,17 @@ class TransactionRecord: transaction_fee (Optional[int]): The total network fee (in tinybars) charged for the transaction. receipt (Optional[TransactionReceipt]): The receipt summarizing the outcome and status of the transaction. call_result (Optional[ContractFunctionResult]): The result of a contract call if the transaction was a smart contract execution. - token_transfers (defaultdict[TokenId, defaultdict[AccountId, int]]): - A mapping of token IDs to account-level transfer amounts. - Represents fungible token movements within the transaction. - nft_transfers (defaultdict[TokenId, list[TokenNftTransfer]]): + token_transfers (defaultdict[TokenId, defaultdict[AccountId, int]]): + A mapping of token IDs to account-level transfer amounts. + Represents fungible token movements within the transaction. + nft_transfers (defaultdict[TokenId, list[TokenNftTransfer]]): A mapping of token IDs to lists of NFT transfers for that token. transfers (defaultdict[AccountId, int]): A mapping of account IDs to hbar transfer amounts (positive for credit, negative for debit). new_pending_airdrops (list[PendingAirdropRecord]):A list of new airdrop records created by this transaction. - + prng_number (Optional[int]): A pseudo-random integer generated by the network (if applicable). prng_bytes (Optional[bytes]): A pseudo-random byte array generated by the network (if applicable). + duplicates (list[TransactionRecord]): A list of duplicate transaction records returned when queried with include_duplicates=True. Empty by default. """ @@ -67,23 +68,30 @@ class TransactionRecord: receipt: Optional[TransactionReceipt] = None call_result: Optional[ContractFunctionResult] = None - token_transfers: defaultdict[TokenId, defaultdict[AccountId, int]] = field(default_factory=lambda: defaultdict(lambda: defaultdict(int))) - nft_transfers: defaultdict[TokenId, list[TokenNftTransfer]] = field(default_factory=lambda: defaultdict(list[TokenNftTransfer])) - transfers: defaultdict[AccountId, int] = field(default_factory=lambda: defaultdict(int)) + token_transfers: defaultdict[TokenId, defaultdict[AccountId, int]] = field( + default_factory=lambda: defaultdict(lambda: defaultdict(int)) + ) + nft_transfers: defaultdict[TokenId, list[TokenNftTransfer]] = field( + default_factory=lambda: defaultdict(list[TokenNftTransfer]) + ) + transfers: defaultdict[AccountId, int] = field( + default_factory=lambda: defaultdict(int) + ) new_pending_airdrops: list[PendingAirdropRecord] = field(default_factory=list) prng_number: Optional[int] = None prng_bytes: Optional[bytes] = None + children: List[TransactionId] = None duplicates: list['TransactionRecord'] = field(default_factory=list) def __repr__(self) -> str: """Returns a human-readable string representation of the TransactionRecord. - - This method constructs a detailed string containing all significant fields of the + + This method constructs a detailed string containing all significant fields of the transaction record including transaction ID, hash, memo, fees, status, transfers, and PRNG results. For the receipt status, it attempts to resolve the numeric status to a human-readable ResponseCode name. - + Returns: str: A string representation showing all significant fields of the TransactionRecord. """ @@ -95,6 +103,20 @@ def __repr__(self) -> str: status = ResponseCode(self.receipt.status).name except (ValueError, AttributeError): status = self.receipt.status + return ( + f"TransactionRecord(transaction_id='{self.transaction_id}', " + f"transaction_hash={self.transaction_hash}, " + f"transaction_memo='{self.transaction_memo}', " + f"transaction_fee={self.transaction_fee}, " + f"receipt_status='{status}', " + f"token_transfers={dict(self.token_transfers)}, " + f"nft_transfers={dict(self.nft_transfers)}, " + f"transfers={dict(self.transfers)}, " + f"new_pending_airdrops={list(self.new_pending_airdrops)}, " + f"call_result={self.call_result}, " + f"prng_number={self.prng_number}, " + f"prng_bytes={self.prng_bytes})" + ) return (f"TransactionRecord(transaction_id='{self.transaction_id}', " f"transaction_hash={self.transaction_hash}, " f"transaction_memo='{self.transaction_memo}', " @@ -114,6 +136,8 @@ def _from_proto( cls, proto: transaction_record_pb2.TransactionRecord, transaction_id: Optional[TransactionId] = None, + children=None, + ) -> "TransactionRecord": duplicates: Optional[list['TransactionRecord']] = None, ) -> 'TransactionRecord': """Creates a TransactionRecord instance from a protobuf transaction record. @@ -132,6 +156,11 @@ def _from_proto( into appropriate defaultdict collections for efficient access. Args: + proto (transaction_record_pb2.TransactionRecord): The raw protobuf + transaction record containing all transaction data. + transaction_id (Optional[TransactionId]): Optional transaction ID to + associate with the record. If not provided, will be extracted from + the protobuf message if available. proto: The raw protobuf transaction record containing all transaction data. transaction_id: The transaction ID to associate with this record (required). duplicates: Optional list of duplicate transaction records to attach. @@ -140,6 +169,28 @@ def _from_proto( Returns: TransactionRecord: A new instance containing all processed and structured data. """ + token_transfers = defaultdict(lambda: defaultdict(int)) + for token_transfer_list in proto.tokenTransferLists: + token_id = TokenId._from_proto(token_transfer_list.token) + for transfer in token_transfer_list.transfers: + account_id = AccountId._from_proto(transfer.accountID) + token_transfers[token_id][account_id] = transfer.amount + + nft_transfers = defaultdict(list[TokenNftTransfer]) + for token_transfer_list in proto.tokenTransferLists: + token_id = TokenId._from_proto(token_transfer_list.token) + nft_transfers[token_id] = TokenNftTransfer._from_proto(token_transfer_list) + + transfers = defaultdict(int) + for transfer in proto.transferList.accountAmounts: + account_id = AccountId._from_proto(transfer.accountID) + transfers[account_id] += transfer.amount + + new_pending_airdrops: list[PendingAirdropRecord] = [] + for pending_airdrop in proto.new_pending_airdrops: + new_pending_airdrops.append( + PendingAirdropRecord._from_proto(pending_airdrop) + ) tx_id = cls._resolve_transaction_id(proto, transaction_id) duplicates = duplicates or [] @@ -148,6 +199,7 @@ def _from_proto( new_pending_airdrops = cls._parse_pending_airdrops(proto) call_result = cls._parse_contract_call_result(proto) + children: Optional[List["TransactionRecord"]] = None return cls( transaction_id=tx_id, transaction_hash=proto.transactionHash, @@ -158,6 +210,12 @@ def _from_proto( nft_transfers=nft_transfers, transfers=transfers, new_pending_airdrops=new_pending_airdrops, + children=children, + call_result=( + ContractFunctionResult._from_proto(proto.contractCallResult) + if proto.HasField("contractCallResult") + else None + ), call_result=call_result, prng_number=proto.prng_number, prng_bytes=proto.prng_bytes, @@ -291,7 +349,9 @@ def _to_proto(self) -> transaction_record_pb2.TransactionRecord: transfer.amount = amount for pending_airdrop in self.new_pending_airdrops: - record_proto.new_pending_airdrops.add().CopyFrom(pending_airdrop._to_proto()) + record_proto.new_pending_airdrops.add().CopyFrom( + pending_airdrop._to_proto() + ) return record_proto \ No newline at end of file diff --git a/tests/unit/transaction_record_query_test.py b/tests/unit/transaction_record_query_test.py index 567786516..d0777ecc4 100644 --- a/tests/unit/transaction_record_query_test.py +++ b/tests/unit/transaction_record_query_test.py @@ -14,6 +14,7 @@ query_pb2, query_header_pb2, ) +from hiero_sdk_python.hapi.services import query_header_pb2 from tests.unit.mock_server import mock_hedera_servers @@ -122,6 +123,30 @@ def test_transaction_record_query_execute(transaction_id): assert result.transaction_fee == 100000 assert result.transaction_hash == b'\x01' * 48 assert result.transaction_memo == "Test transaction" + +from hiero_sdk_python.hapi.services import query_header_pb2, transaction_get_record_pb2 +def test_transaction_record_query_with_children_mapping(transaction_id): + + child_proto = transaction_record_pb2.TransactionRecord( + memo="Child Record" + ) + child_proto.transactionID.CopyFrom(transaction_id._to_proto()) + + record_response = transaction_get_record_pb2.TransactionGetRecordResponse() + + record_response.header.nodeTransactionPrecheckCode = ResponseCode.OK + + record_response.transactionRecord.memo = "Parent Record" + record_response.transactionRecord.transactionID.CopyFrom(transaction_id._to_proto()) + + record_response.child_transaction_records.extend([child_proto, child_proto]) + + query = TransactionRecordQuery(transaction_id) + children = query._map_record_list(record_response.child_transaction_records) + + assert len(children) == 2 + assert children[0].transaction_memo == "Child Record" + def test_transaction_record_query_execute_with_duplicates(transaction_id): """Test TransactionRecordQuery returns duplicates when include_duplicates=True."""