diff --git a/.github/ISSUE_TEMPLATE/00-good-first-issue-candidate.yml b/.github/ISSUE_TEMPLATE/00-good-first-issue-candidate.yml index 11c25b815..70807661f 100644 --- a/.github/ISSUE_TEMPLATE/00-good-first-issue-candidate.yml +++ b/.github/ISSUE_TEMPLATE/00-good-first-issue-candidate.yml @@ -82,7 +82,7 @@ body: - The complete steps to implement the solution - What the final result or output should look like - Links to relevant documentation or code (if helpful) - For good first issues, please keep this as guided and clear as possible. + For Good First Issues, please keep this as guided and clear as possible. value: | @@ -95,7 +95,7 @@ body: attributes: label: 🧠 Good First Issue Developers — Prerequisites & Expectations description: | - Adapt as needed. Concrete requirements before claiming this good first issue. Adapt as needed. + Adapt as needed. Concrete requirements before claiming this Good First Issue. value: | > [!IMPORTANT] > **This issue does not require prior domain knowledge.** diff --git a/.github/ISSUE_TEMPLATE/01-good-first-issue.yml b/.github/ISSUE_TEMPLATE/01-good-first-issue.yml index bfcff5101..744822160 100644 --- a/.github/ISSUE_TEMPLATE/01-good-first-issue.yml +++ b/.github/ISSUE_TEMPLATE/01-good-first-issue.yml @@ -69,7 +69,7 @@ body: - The complete steps to implement the solution - What the final result or output should look like - Links to relevant documentation or code (if helpful) - For good first issues, please keep this as guided and clear as possible. + For Good First Issues, please keep this as guided and clear as possible. value: | @@ -82,7 +82,7 @@ body: attributes: label: 🧠 Good First Issue Developers — Prerequisites & Expectations description: | - Adapt as needed. Concrete requirements before claiming this good first issue. Adapt as needed. + Adapt as needed. Concrete requirements before claiming this Good First Issue. value: | > [!IMPORTANT] > **This issue does not require prior domain knowledge.** diff --git a/.github/ISSUE_TEMPLATE/02-beginner-issue.yml b/.github/ISSUE_TEMPLATE/02-beginner-issue.yml index 25d0b6a47..49be81c1f 100644 --- a/.github/ISSUE_TEMPLATE/02-beginner-issue.yml +++ b/.github/ISSUE_TEMPLATE/02-beginner-issue.yml @@ -98,7 +98,7 @@ body: > **Beginner issues are low-risk but we expect a working solution.** > [!IMPORTANT] - > We recommend completing at least 3 good first issues before attempting a beginner issue. + > We recommend completing at least 3 Good First Issues before attempting a beginner issue. **Difficulty:** @@ -113,7 +113,7 @@ body: > - Keeping your fork up to date with main by [rebasing](https://github.com/hiero-ledger/sdk-collaboration-hub/blob/main/guides/issue-progression/for-developers/rebasing.md) > - Looking things up on your own before asking for help - If any of that feels unfamiliar, good first issues might still be the most rewarding path for you right now — [find one here](https://github.com/issues?q=is%3Aopen%20is%3Aissue%20org%3Ahiero-ledger%20archived%3Afalse%20no%3Aassignee%20(label%3A%22good%20first%20issue%22%20OR%20label%3A%22skill%3A%20good%20first%20issue%22)%20(repo%3Ahiero-ledger%2Fhiero-sdk-cpp%20OR%20repo%3Ahiero-ledger%2Fhiero-sdk-swift%20OR%20repo%3Ahiero-ledger%2Fhiero-sdk-python%20OR%20repo%3Ahiero-ledger%2Fhiero-sdk-js%20OR%20repo%3Ahiero-ledger%2Fhiero-website)). + If any of that feels unfamiliar, Good First Issues might still be the most rewarding path for you right now — [find one here](https://github.com/issues?q=is%3Aopen%20is%3Aissue%20org%3Ahiero-ledger%20archived%3Afalse%20no%3Aassignee%20(label%3A%22good%20first%20issue%22%20OR%20label%3A%22skill%3A%20good%20first%20issue%22)%20(repo%3Ahiero-ledger%2Fhiero-sdk-cpp%20OR%20repo%3Ahiero-ledger%2Fhiero-sdk-swift%20OR%20repo%3Ahiero-ledger%2Fhiero-sdk-python%20OR%20repo%3Ahiero-ledger%2Fhiero-sdk-js%20OR%20repo%3Ahiero-ledger%2Fhiero-website)). You can always come back when you are ready. ### ⚠️ AI Usage Policy diff --git a/.github/scripts/bot-gfi-assign-on-comment.js b/.github/scripts/bot-gfi-assign-on-comment.js index 730a810aa..7a43857b9 100644 --- a/.github/scripts/bot-gfi-assign-on-comment.js +++ b/.github/scripts/bot-gfi-assign-on-comment.js @@ -60,7 +60,7 @@ function commentRequestsAssignment(body) { } /** - * Returns true if the issue has the good first issue label. + * Returns true if the issue has the Good First Issue label. */ function issueIsGoodFirstIssue(issue) { const labels = issue?.labels?.map(label => label.name) ?? []; diff --git a/.github/scripts/coderabbit_plan_trigger.js b/.github/scripts/coderabbit_plan_trigger.js index bffb0d439..eaca959d0 100644 --- a/.github/scripts/coderabbit_plan_trigger.js +++ b/.github/scripts/coderabbit_plan_trigger.js @@ -2,14 +2,9 @@ const CODERABBIT_MARKER = ''; -async function triggerCodeRabbitPlan(github, owner, repo, issue, marker = CODERABBIT_MARKER, isDryRun = false) { +async function triggerCodeRabbitPlan(github, owner, repo, issue, marker = CODERABBIT_MARKER) { const comment = `${marker} @coderabbitai plan`; - if (isDryRun) { - console.log(`[DRY RUN] Would trigger CodeRabbit plan for issue #${issue.number}`); - return true; - } - try { await github.rest.issues.createComment({ owner, @@ -105,11 +100,8 @@ async function main({ github, context }) { return console.log(`CodeRabbit plan already triggered for #${issue.number}`); } - // Check for dry run (default to true if not specified, for safety) - const isDryRun = (process.env.DRY_RUN || 'true').toLowerCase() === 'true'; - // Post CodeRabbit plan trigger - await triggerCodeRabbitPlan(github, owner, repo, issue, CODERABBIT_MARKER, isDryRun); + await triggerCodeRabbitPlan(github, owner, repo, issue, CODERABBIT_MARKER); logSummary(owner, repo, issue); } catch (err) { diff --git a/.github/workflows/bot-coderabbit-plan-trigger.yml b/.github/workflows/bot-coderabbit-plan-trigger.yml index 66ecc0a40..4a147b1e6 100644 --- a/.github/workflows/bot-coderabbit-plan-trigger.yml +++ b/.github/workflows/bot-coderabbit-plan-trigger.yml @@ -5,16 +5,6 @@ name: CodeRabbit Plan Trigger on: issues: types: [labeled] - workflow_dispatch: - inputs: - dry_run: - description: "Run without posting comments" - required: false - default: "true" - type: choice - options: - - "true" - - "false" permissions: issues: write @@ -28,10 +18,9 @@ jobs: cancel-in-progress: false # Only run when the newly added label is beginner, intermediate, or advanced (case-insensitive) if: > - github.event_name == 'workflow_dispatch' || - (github.event.action == 'labeled' && + github.event.action == 'labeled' && github.event.issue.state == 'open' && - contains(fromJson('["beginner","intermediate","advanced","Beginner","Intermediate","Advanced"]'), github.event.label.name)) + contains(fromJson('["beginner","intermediate","advanced","Beginner","Intermediate","Advanced"]'), github.event.label.name) steps: - name: Harden the runner @@ -45,7 +34,6 @@ jobs: - name: Trigger CodeRabbit Plan env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9.0.0 with: script: | diff --git a/src/hiero_sdk_python/consensus/topic_create_transaction.py b/src/hiero_sdk_python/consensus/topic_create_transaction.py index 82be5de98..45083c8be 100644 --- a/src/hiero_sdk_python/consensus/topic_create_transaction.py +++ b/src/hiero_sdk_python/consensus/topic_create_transaction.py @@ -11,6 +11,7 @@ from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.channels import _Channel +from hiero_sdk_python.crypto.key import Key from hiero_sdk_python.Duration import Duration from hiero_sdk_python.executable import _Method from hiero_sdk_python.hapi.services import consensus_create_topic_pb2, transaction_pb2 @@ -19,7 +20,7 @@ ) from hiero_sdk_python.tokens.custom_fixed_fee import CustomFixedFee from hiero_sdk_python.transaction.transaction import Transaction -from hiero_sdk_python.utils.key_utils import Key, key_to_proto +from hiero_sdk_python.utils.key_utils import key_to_proto class TopicCreateTransaction(Transaction): diff --git a/src/hiero_sdk_python/query/transaction_get_receipt_query.py b/src/hiero_sdk_python/query/transaction_get_receipt_query.py index 73b751032..e73f8eaf1 100644 --- a/src/hiero_sdk_python/query/transaction_get_receipt_query.py +++ b/src/hiero_sdk_python/query/transaction_get_receipt_query.py @@ -277,17 +277,22 @@ def _map_status_error(self, response: response_pb2.Response) -> PrecheckError | TransactionReceipt._from_proto(response.transactionGetReceipt.receipt, self.transaction_id), ) - def _map_receipt_list(self, receipts: list[transaction_receipt_pb2.TransactionReceipt]) -> list[TransactionReceipt]: + def _map_receipt_list( + self, receipts: list[transaction_receipt_pb2.TransactionReceipt], include_parent_tx_id: bool = False + ) -> list[TransactionReceipt]: """ Maps a list of protobuf transaction receipts to TransactionReceipt objects. Args: receipts: A list of protobuf TransactionReceipt objects + include_parent_tx_id: If True, pass parent transaction_id to mapped receipts (for duplicates). + If False, pass None (for child receipts). Returns: A list of TransactionReceipt objects """ - return [TransactionReceipt._from_proto(receipt_proto, self.transaction_id) for receipt_proto in receipts] + transaction_id = self.transaction_id if include_parent_tx_id else None + return [TransactionReceipt._from_proto(receipt_proto, transaction_id) for receipt_proto in receipts] def execute(self, client: Client, timeout: int | float | None = None) -> TransactionReceipt: """ @@ -315,12 +320,18 @@ def execute(self, client: Client, timeout: int | float | None = None) -> Transac parent = TransactionReceipt._from_proto(response.transactionGetReceipt.receipt, self.transaction_id) if self.include_children: - children = self._map_receipt_list(response.transactionGetReceipt.child_transaction_receipts) + # Child receipts are sub-transactions; they don't need parent transaction_id + children = self._map_receipt_list( + response.transactionGetReceipt.child_transaction_receipts, include_parent_tx_id=False + ) parent._set_children(children) if self.include_duplicates: - duplicates = self._map_receipt_list(response.transactionGetReceipt.duplicateTransactionReceipts) + # Duplicate receipts are related to parent; keep parent transaction_id for context + duplicates = self._map_receipt_list( + response.transactionGetReceipt.duplicateTransactionReceipts, include_parent_tx_id=True + ) parent._set_duplicates(duplicates) diff --git a/src/hiero_sdk_python/tokens/token_create_transaction.py b/src/hiero_sdk_python/tokens/token_create_transaction.py index 1f8e363e5..bd0dc8790 100644 --- a/src/hiero_sdk_python/tokens/token_create_transaction.py +++ b/src/hiero_sdk_python/tokens/token_create_transaction.py @@ -18,6 +18,7 @@ from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.channels import _Channel +from hiero_sdk_python.crypto.key import Key from hiero_sdk_python.Duration import Duration from hiero_sdk_python.executable import _Method from hiero_sdk_python.hapi.services import token_create_pb2, transaction_pb2 @@ -29,7 +30,7 @@ from hiero_sdk_python.tokens.supply_type import SupplyType from hiero_sdk_python.tokens.token_type import TokenType from hiero_sdk_python.transaction.transaction import Transaction -from hiero_sdk_python.utils.key_utils import Key, key_to_proto +from hiero_sdk_python.utils.key_utils import key_to_proto AUTO_RENEW_PERIOD = Duration(7890000) # around 90 days in seconds diff --git a/src/hiero_sdk_python/tokens/token_update_transaction.py b/src/hiero_sdk_python/tokens/token_update_transaction.py index 5f0af078d..3361858dc 100644 --- a/src/hiero_sdk_python/tokens/token_update_transaction.py +++ b/src/hiero_sdk_python/tokens/token_update_transaction.py @@ -14,6 +14,7 @@ from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.channels import _Channel +from hiero_sdk_python.crypto.key import Key from hiero_sdk_python.Duration import Duration from hiero_sdk_python.executable import _Method from hiero_sdk_python.hapi.services import token_update_pb2, transaction_pb2 @@ -25,7 +26,7 @@ from hiero_sdk_python.tokens.token_id import TokenId from hiero_sdk_python.tokens.token_key_validation import TokenKeyValidation from hiero_sdk_python.transaction.transaction import Transaction -from hiero_sdk_python.utils.key_utils import Key, key_to_proto +from hiero_sdk_python.utils.key_utils import key_to_proto @dataclass diff --git a/src/hiero_sdk_python/transaction/transaction.py b/src/hiero_sdk_python/transaction/transaction.py index 7b1369de9..692877c20 100644 --- a/src/hiero_sdk_python/transaction/transaction.py +++ b/src/hiero_sdk_python/transaction/transaction.py @@ -5,6 +5,7 @@ from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.client.client import Client +from hiero_sdk_python.crypto.key import Key from hiero_sdk_python.exceptions import PrecheckError from hiero_sdk_python.executable import _Executable, _ExecutionState from hiero_sdk_python.hapi.services import basic_types_pb2, transaction_contents_pb2, transaction_pb2 @@ -15,7 +16,7 @@ from hiero_sdk_python.transaction.transaction_id import TransactionId from hiero_sdk_python.transaction.transaction_receipt import TransactionReceipt from hiero_sdk_python.transaction.transaction_response import TransactionResponse -from hiero_sdk_python.utils.key_utils import Key, key_to_proto +from hiero_sdk_python.utils.key_utils import key_to_proto if TYPE_CHECKING: diff --git a/src/hiero_sdk_python/transaction/transaction_receipt.py b/src/hiero_sdk_python/transaction/transaction_receipt.py index eaf7261d0..c67cf558d 100644 --- a/src/hiero_sdk_python/transaction/transaction_receipt.py +++ b/src/hiero_sdk_python/transaction/transaction_receipt.py @@ -88,10 +88,15 @@ def account_id(self) -> AccountId | None: """ Retrieves the AccountId associated with the transaction receipt, if available. + Unlike other ID properties (token_id, file_id, etc.), this returns the AccountId + even when accountNum is 0. This is intentional to support EVM auto-account creation + scenarios where the protobuf may contain an AccountID with num=0 to identify the + newly created account before it's fully initialized on-chain. + Returns: - AccountId or None: The AccountId if present; otherwise, None. + AccountId or None: The AccountId if present (including num=0); otherwise, None. """ - if self._receipt_proto.HasField("accountID") and self._receipt_proto.accountID.accountNum != 0: + if self._receipt_proto.HasField("accountID"): return AccountId._from_proto(self._receipt_proto.accountID) return None @@ -107,7 +112,9 @@ def serial_numbers(self) -> list[int]: @property def file_id(self) -> FileId | None: - """Returns the file ID associated with this receipt.""" + """ + Returns the file ID associated with this receipt. + """ if self._receipt_proto.HasField("fileID") and self._receipt_proto.fileID.fileNum != 0: return FileId._from_proto(self._receipt_proto.fileID) return None @@ -243,14 +250,14 @@ def _to_proto(self): @classmethod def _from_proto( - cls, proto: transaction_receipt_pb2.TransactionReceipt, transaction_id: TransactionId + cls, proto: transaction_receipt_pb2.TransactionReceipt, transaction_id: TransactionId | None ) -> TransactionReceipt: """ Creates a TransactionReceipt instance from a protobuf TransactionReceipt object. Args: proto (transaction_receipt_pb2.TransactionReceipt): The protobuf TransactionReceipt object. - transaction_id (TransactionId): The transaction ID associated with this receipt. + transaction_id (TransactionId | None): The transaction ID associated with this receipt. Can be None for child receipts. Returns: TransactionReceipt: A new instance of TransactionReceipt populated with data from the protobuf object. diff --git a/tests/integration/account_records_query_e2e_test.py b/tests/integration/account_records_query_e2e_test.py index f4cb39a3f..ea1ca7586 100644 --- a/tests/integration/account_records_query_e2e_test.py +++ b/tests/integration/account_records_query_e2e_test.py @@ -20,9 +20,10 @@ def test_integration_account_record_query_can_execute(env): account = env.create_account() # Perform transfer transaction + transfer_tx = TransferTransaction() + transfer_tx.transaction_fee = Hbar(2).to_tinybars() transfer_receipt = ( - TransferTransaction() - .add_hbar_transfer(account.id, Hbar(1).to_tinybars()) + transfer_tx.add_hbar_transfer(account.id, Hbar(1).to_tinybars()) .add_hbar_transfer(env.operator_id, -Hbar(1).to_tinybars()) .execute(env.client) ) @@ -50,9 +51,10 @@ def test_integration_account_record_query_get_cost(env): """Test that AccountRecordsQuery can calculate query costs.""" account = env.create_account() + transfer_tx = TransferTransaction() + transfer_tx.transaction_fee = Hbar(2).to_tinybars() transfer_receipt = ( - TransferTransaction() - .add_hbar_transfer(account.id, Hbar(1).to_tinybars()) + transfer_tx.add_hbar_transfer(account.id, Hbar(1).to_tinybars()) .add_hbar_transfer(env.operator_id, -Hbar(1).to_tinybars()) .execute(env.client) ) @@ -75,9 +77,10 @@ def test_integration_account_record_query_insufficient_payment(env): """Test that AccountRecordsQuery fails with insufficient payment.""" account = env.create_account() + transfer_tx = TransferTransaction() + transfer_tx.transaction_fee = Hbar(2).to_tinybars() transfer_receipt = ( - TransferTransaction() - .add_hbar_transfer(account.id, Hbar(1).to_tinybars()) + transfer_tx.add_hbar_transfer(account.id, Hbar(1).to_tinybars()) .add_hbar_transfer(env.operator_id, -Hbar(1).to_tinybars()) .execute(env.client) ) diff --git a/tests/integration/transaction_get_receipt_query_e2e_test.py b/tests/integration/transaction_get_receipt_query_e2e_test.py index abcc56465..4b458e727 100644 --- a/tests/integration/transaction_get_receipt_query_e2e_test.py +++ b/tests/integration/transaction_get_receipt_query_e2e_test.py @@ -74,6 +74,64 @@ def _submit_simple_transfer(env, node_ids=None, tx_id=None): return _extract_tx_id(tx, receipt) +def _setup_contract_and_execute(env, memo): + """ + Helper to setup and execute a contract transaction. + + Returns the transaction ID of the contract execute operation. + Raises AssertionError if transaction ID cannot be extracted (triggering pytest.skip). + """ + from examples.contract.contracts.contract_utils import ( + CONTRACT_DEPLOY_GAS, + STATEFUL_CONTRACT_BYTECODE, + ) + from hiero_sdk_python.contract.contract_create_transaction import ContractCreateTransaction + from hiero_sdk_python.contract.contract_execute_transaction import ContractExecuteTransaction + from hiero_sdk_python.contract.contract_function_parameters import ContractFunctionParameters + from hiero_sdk_python.file.file_create_transaction import FileCreateTransaction + + # Upload contract bytecode + file_receipt = ( + FileCreateTransaction() + .set_keys(env.operator_key.public_key()) + .set_contents(STATEFUL_CONTRACT_BYTECODE) + .set_file_memo(memo) + .execute(env.client) + ) + assert file_receipt.status == ResponseCode.SUCCESS + file_id = file_receipt.file_id + assert file_id is not None, "File ID should not be None after successful file creation" + + # Deploy contract + constructor_params = ContractFunctionParameters().add_bytes32(b"Initial message") + contract_receipt = ( + ContractCreateTransaction() + .set_admin_key(env.operator_key.public_key()) + .set_gas(CONTRACT_DEPLOY_GAS) + .set_constructor_parameters(constructor_params) + .set_bytecode_file_id(file_id) + .execute(env.client) + ) + assert contract_receipt.status == ResponseCode.SUCCESS + contract_id = contract_receipt.contract_id + assert contract_id is not None, "Contract ID should not be None after successful contract creation" + + # Execute contract transaction + execute_tx = ( + ContractExecuteTransaction() + .set_contract_id(contract_id) + .set_gas(1_000_000) + .set_function( + "setMessage", + ContractFunctionParameters().add_bytes32(b"Test".ljust(32, b"\x00")), + ) + ) + execute_receipt = execute_tx.execute(env.client) + assert execute_receipt.status == ResponseCode.SUCCESS + + return _extract_tx_id(execute_tx, execute_receipt) + + @pytest.mark.integration def test_get_receipt_query_children_empty_when_not_requested_e2e(env): """ @@ -236,3 +294,217 @@ def test_get_receipt_throws_status_error_when_validation_enabled(env): query.execute(env.client) assert e.value.status == ResponseCode.INVALID_ACCOUNT_ID + + +@pytest.mark.integration +def test_get_receipt_query_child_receipts_have_none_transaction_id_e2e(env): + """ + E2E: + Verify that child receipts have transaction_id = None (they are independent transactions). + + Rationale: Child receipts are sub-transactions created by the parent transaction. + They should have their own identity and not inherit the parent's transaction_id. + """ + try: + tx_id = _setup_contract_and_execute(env, "child receipt transaction_id test") + except AssertionError as e: + pytest.skip(str(e)) + + # Query with include_children + queried = TransactionGetReceiptQuery().set_transaction_id(tx_id).set_include_children(True).execute(env.client) + + assert queried.status == ResponseCode.SUCCESS + + # Verify: if children exist, each child has transaction_id = None + if len(queried.children) > 0: + for child_receipt in queried.children: + assert child_receipt.status is not None + # CRITICAL FIX VERIFICATION: Child receipts must have transaction_id = None + # because they are independent sub-transactions, not duplicates of the parent + assert child_receipt.transaction_id is None, ( + "Child receipt must have transaction_id=None (independent identity)" + ) + + +@pytest.mark.integration +@pytest.mark.xfail(reason="Uses threads to create duplicate tx, may be flaky") +def test_get_receipt_query_duplicate_receipts_retain_parent_transaction_id_e2e(env): + """ + E2E: + Verify that duplicate receipts retain the parent transaction_id for context. + + Rationale: Duplicates are the same transaction submitted to different nodes. + They should preserve the parent transaction_id to show the relationship. + """ + tx_id = TransactionId.generate(env.operator_id) + nodes = env.client.network.nodes + + # Need at least 2 nodes to potentially create duplicates + if len(nodes) < 2: + pytest.skip("Not enough nodes to create duplicate receipts") + + node_ids = [nodes[0]._account_id, nodes[1]._account_id] + + # Submit same transaction to different nodes + tx1 = threading.Thread(target=_submit_simple_transfer, args=(env, [node_ids[0]], tx_id)) + tx2 = threading.Thread(target=_submit_simple_transfer, args=(env, [node_ids[1]], tx_id)) + + tx1.start() + tx2.start() + tx1.join() + tx2.join() + + # Query with include_duplicates + queried = TransactionGetReceiptQuery().set_transaction_id(tx_id).set_include_duplicates(True).execute(env.client) + + assert queried.status == ResponseCode.SUCCESS + assert isinstance(queried.duplicates, list) + + # Verify: if duplicates exist, each duplicate retains parent transaction_id + if len(queried.duplicates) > 0: + for duplicate_receipt in queried.duplicates: + assert duplicate_receipt.status is not None + # CRITICAL FIX VERIFICATION: Duplicate receipts must keep parent transaction_id + # to maintain the relationship and context with the original transaction + assert duplicate_receipt.transaction_id == tx_id, ( + f"Duplicate receipt must have transaction_id={tx_id} (same as parent)" + ) + + +@pytest.mark.integration +def test_get_receipt_query_child_receipt_mapping_e2e(env): + """ + E2E: + Verify child receipts are properly mapped with transaction_id=None (independent identity). + + Rationale: The fix for #1849 ensures child receipts have None transaction_id instead of + inheriting parent transaction_id. This test verifies the mapping behavior. + + Note: While this test uses contract execution, the key verification is that the + TransactionGetReceiptQuery correctly maps child receipts with None transaction_id, + regardless of whether children are populated. The unit tests verify account_id + accessibility with populated accountID fields. + """ + try: + tx_id = _setup_contract_and_execute(env, "child receipt mapping test") + except AssertionError as e: + pytest.skip(str(e)) + + # Query with include_children + queried = TransactionGetReceiptQuery().set_transaction_id(tx_id).set_include_children(True).execute(env.client) + + assert queried.status == ResponseCode.SUCCESS + + # Verify mapping behavior - all child receipts should have transaction_id = None + for child_receipt in queried.children: + assert child_receipt.status is not None + # CRITICAL: Child receipts must map with transaction_id=None (independent transactions) + assert child_receipt.transaction_id is None, ( + "Child receipt must have transaction_id=None (independent identity, not parent context)" + ) + + +@pytest.mark.integration +def test_get_receipt_query_child_receipt_account_id_from_auto_account_creation_e2e(env): + """ + E2E: + Verify that child receipts have accessible account_id from auto-account creation. + + This directly tests the fix for issue #1849: account_id property was filtering out + accountNum==0 values, which blocked access to EVM auto-created accounts. + + Approach: + - Create AccountId with just EVM address (triggers auto-account creation on transfer) + - Transfer to that account (creates child receipt with populated accountID) + - Query with include_children and verify account_id is accessible + + This test may skip if the network doesn't populate child receipts for transfers, + but when child receipts exist, we verify the fix is working. + """ + # Create AccountId with EVM address to trigger auto-account creation + # Using a valid EVM address format (20 bytes) + evm_address = bytes.fromhex("1234567890abcdef1234567890abcdef12345678") + receiver = AccountId.from_evm_address(evm_address.hex(), 0, 0) + + # Transfer to trigger auto-account creation (may produce child receipt with accountID) + tx = ( + TransferTransaction() + .add_hbar_transfer(env.operator_id, Hbar(-0.01).to_tinybars()) + .add_hbar_transfer(receiver, Hbar(0.01).to_tinybars()) + ) + receipt = tx.execute(env.client) + assert receipt.status == ResponseCode.SUCCESS + + try: + tx_id = _extract_tx_id(tx, receipt) + except AssertionError: + pytest.skip("Could not extract transaction ID") + + # Query with include_children + queried = TransactionGetReceiptQuery().set_transaction_id(tx_id).set_include_children(True).execute(env.client) + + assert queried.status == ResponseCode.SUCCESS + + # Verify: if child receipts exist, account_id should be accessible + if len(queried.children) > 0: + for child_receipt in queried.children: + assert child_receipt.status is not None + # FIX VERIFICATION: account_id must be accessible (not filtered for accountNum==0) + if child_receipt.account_id is not None: + # Account_id should be a valid AccountId instance + assert isinstance(child_receipt.account_id, AccountId), ( + "child_receipt.account_id should be AccountId instance" + ) + # Verify basic structure (even if accountNum is 0) + assert hasattr(child_receipt.account_id, "shard"), "account_id should have shard attribute" + assert hasattr(child_receipt.account_id, "realm"), "account_id should have realm attribute" + assert hasattr(child_receipt.account_id, "num"), "account_id should have num attribute" + + +@pytest.mark.integration +@pytest.mark.xfail( + reason="Flaky: Network conditions may prevent duplicates from being created. Concurrent thread access to env.create_account() may have race conditions." +) +def test_get_receipt_query_duplicate_receipts_mapping_e2e(env): + """ + E2E: + Verify duplicate receipts are properly mapped with transaction_id=parent (context preservation). + + Rationale: The fix for #1849 ensures duplicate receipts retain parent transaction_id + to maintain context, unlike child receipts which have None. + + This test is marked xfail because: + 1. Network conditions may not produce duplicates + 2. Concurrent calls to env.create_account() may have shared state issues + 3. Testing environment may not guarantee duplicate receipt creation + """ + tx_id = TransactionId.generate(env.operator_id) + nodes = env.client.network.nodes + + if len(nodes) < 2: + pytest.skip("Not enough nodes to create duplicate receipts") + + node_ids = [nodes[0]._account_id, nodes[1]._account_id] + + # Submit same transaction to different nodes + tx1 = threading.Thread(target=_submit_simple_transfer, args=(env, [node_ids[0]], tx_id)) + tx2 = threading.Thread(target=_submit_simple_transfer, args=(env, [node_ids[1]], tx_id)) + + tx1.start() + tx2.start() + tx1.join() + tx2.join() + + # Query with include_duplicates + queried = TransactionGetReceiptQuery().set_transaction_id(tx_id).set_include_duplicates(True).execute(env.client) + + assert queried.status == ResponseCode.SUCCESS + assert isinstance(queried.duplicates, list) + + # Verify mapping behavior - all duplicate receipts should retain parent transaction_id + if len(queried.duplicates) > 0: + for duplicate_receipt in queried.duplicates: + # CRITICAL: Duplicate receipts must map with parent transaction_id (context preservation) + assert duplicate_receipt.transaction_id == tx_id, ( + f"Duplicate receipt must have transaction_id={tx_id} (same as parent for context)" + ) diff --git a/tests/unit/get_receipt_query_test.py b/tests/unit/get_receipt_query_test.py index d1c6c1301..2e1b27a78 100644 --- a/tests/unit/get_receipt_query_test.py +++ b/tests/unit/get_receipt_query_test.py @@ -348,3 +348,123 @@ def test_transaction_receipt_query_should_raise_receipt_error(transaction_id): query.execute(client) assert e.value.status == ResponseCode.INVALID_SIGNATURE + + +def test_child_receipts_with_account_id(transaction_id): + """Test that child receipts can have accountID populated from network response.""" + child_account_id = basic_types_pb2.AccountID(shardNum=0, realmNum=0, accountNum=999) + + response = response_pb2.Response( + transactionGetReceipt=transaction_get_receipt_pb2.TransactionGetReceiptResponse( + header=response_header_pb2.ResponseHeader(nodeTransactionPrecheckCode=ResponseCode.OK), + receipt=transaction_receipt_pb2.TransactionReceipt(status=ResponseCode.SUCCESS), + child_transaction_receipts=[ + transaction_receipt_pb2.TransactionReceipt(status=ResponseCode.SUCCESS, accountID=child_account_id), + ], + ) + ) + + response_sequences = [[response]] + + with mock_hedera_servers(response_sequences) as client: + query = TransactionGetReceiptQuery().set_transaction_id(transaction_id).set_include_children(True) + + result = query.execute(client) + + assert len(result.children) == 1 + child_receipt = result.children[0] + + # Verify: child receipt has account_id accessible and transaction_id is None + assert child_receipt.account_id is not None + assert child_receipt.account_id.num == 999 + assert child_receipt.transaction_id is None + + +def test_child_receipts_with_zero_accountnum(transaction_id): + """ + Test child receipt with accountNum=0 (common for EVM auto-account creation). + Verifies that account_id is still accessible even with accountNum=0. + """ + # EVM auto-created accounts may have accountNum=0 initially + child_account_id = basic_types_pb2.AccountID(shardNum=0, realmNum=0, accountNum=0) + + response = response_pb2.Response( + transactionGetReceipt=transaction_get_receipt_pb2.TransactionGetReceiptResponse( + header=response_header_pb2.ResponseHeader(nodeTransactionPrecheckCode=ResponseCode.OK), + receipt=transaction_receipt_pb2.TransactionReceipt(status=ResponseCode.SUCCESS), + child_transaction_receipts=[ + transaction_receipt_pb2.TransactionReceipt(status=ResponseCode.SUCCESS, accountID=child_account_id), + ], + ) + ) + + response_sequences = [[response]] + + with mock_hedera_servers(response_sequences) as client: + query = TransactionGetReceiptQuery().set_transaction_id(transaction_id).set_include_children(True) + + result = query.execute(client) + + assert len(result.children) == 1 + child_receipt = result.children[0] + + # Verify: account_id is accessible even with accountNum=0 + assert child_receipt.account_id is not None + assert child_receipt.account_id.num == 0 + assert child_receipt.account_id.shard == 0 + assert child_receipt.account_id.realm == 0 + assert child_receipt.transaction_id is None + + +def test_duplicate_receipts_with_account_id(transaction_id): + """Test that duplicate receipts can have accountID populated from network response.""" + duplicate_account_id = basic_types_pb2.AccountID(shardNum=0, realmNum=0, accountNum=999) + + response = response_pb2.Response( + transactionGetReceipt=transaction_get_receipt_pb2.TransactionGetReceiptResponse( + header=response_header_pb2.ResponseHeader(nodeTransactionPrecheckCode=ResponseCode.OK), + receipt=transaction_receipt_pb2.TransactionReceipt(status=ResponseCode.SUCCESS), + duplicateTransactionReceipts=[ + transaction_receipt_pb2.TransactionReceipt( + status=ResponseCode.SUCCESS, + accountID=duplicate_account_id, + ), + ], + ) + ) + + with mock_hedera_servers([[response]]) as client: + result = ( + TransactionGetReceiptQuery().set_transaction_id(transaction_id).set_include_duplicates(True).execute(client) + ) + + assert len(result.duplicates) == 1 + duplicate_receipt = result.duplicates[0] + + # Verify: duplicate receipt has account_id accessible + assert duplicate_receipt.account_id is not None + assert duplicate_receipt.account_id.num == 999 + # Duplicates retain parent transaction_id for context (unlike children which have None) + assert duplicate_receipt.transaction_id == transaction_id + + +def test_account_id_returns_none_when_not_set(transaction_id): + """Test that account_id returns None when accountID is not set in the protobuf.""" + response = response_pb2.Response( + transactionGetReceipt=transaction_get_receipt_pb2.TransactionGetReceiptResponse( + header=response_header_pb2.ResponseHeader(nodeTransactionPrecheckCode=ResponseCode.OK), + receipt=transaction_receipt_pb2.TransactionReceipt( + status=ResponseCode.SUCCESS + # Note: accountID is NOT set + ), + child_transaction_receipts=[], + ) + ) + + with mock_hedera_servers([[response]]) as client: + result = ( + TransactionGetReceiptQuery().set_transaction_id(transaction_id).set_include_children(True).execute(client) + ) + + # Verify: account_id is None when accountID is not set in protobuf + assert result.account_id is None