diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b91a214..0141bc37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,21 +6,17 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. ## [Unreleased] - ### Added - ### Changed - Refactored token-related example scripts (`token_delete.py`, `token_dissociate.py`, etc.) for improved readability and modularity. [#370] - ### Fixed - - ## [0.1.8] - 2025-11-07 ### Added + - Add `TokenFeeScheduleUpdateTransaction` class to support updating custom fee schedules on tokens (#471). - Add `examples/token_update_fee_schedule_fungible.py` and `examples/token_update_fee_schedule_nft.py` demonstrating the use of `TokenFeeScheduleUpdateTransaction`. - Update `docs/sdk_users/running_examples.md` to include `TokenFeeScheduleUpdateTransaction`. @@ -43,15 +39,16 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - docs: Add `docs/sdk_developers/project_structure.md` to explain repository layout and import paths. ### Changed + - chore: bumped solo action from 14.0 to 15.0 (#764) - chore: replaced hardcoded 'testnet' messages with environment network name -- chore: validate that token airdrop transactions require an available token service on the channel (#632) +- chore: validate that token airdrop transactions require an available token service on the channel (#632) - chore: update local environment configuration in env.example (#649) - chore: Update env.example NETWORK to encourage testnet or local usage (#659) - chore: updated pyproject.toml with python 3.10 to 3.13 (#510, #449) - chore: fix type hint for TokenCancelAirdropTransaction pending_airdrops parameter - chore: Moved documentation file `common_issues.md` from `examples/sdk_developers/` to `docs/sdk_developers/` for unified documentation management (#516). -- chore: Refactored the script of examples/custom_fee.py into modular functions +- chore: Refactored the script of examples/custom_fee.py into modular functions - fix: Replaced `collections.namedtuple` with `typing.NamedTuple` in `client.py` for improved type checking. - chore: Refactored examples/custom_fee.py into three separate example files. - Expanded `docs/sdk_developers/checklist.md` with a self-review guide for all pull request submission requirements (#645). @@ -61,6 +58,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - Refactor `AbstractTokenTransferTransaction` to unify Token/NFT transfer logic. ### Fixed + - Added explicit read permissions to examples.yml (#623) - Removed deprecated Logger.warn() method and legacy parameter swap logic from get_logger() (#673). - Improved type hinting in `file_append_transaction.py` to resolve 'mypy --strict` errors. ([#495](https://github.com/hiero-ledger/hiero-sdk-python/issues/495)) @@ -70,12 +68,13 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - Fixed incorrect `TokenType` import (protobuf vs. SDK enum) in 18 example files. - Update `schedule_sign_transaction_e2e_test` to check for key presence instead of relying on index. - Add `localhost` and `local` as network names - + ### Breaking Changes + - chore: changed the file names airdrop classes (#631) -{pending_airdrop_id.py -> token_airdrop_pending_id.py} -{pending_airdrop_record.py -> token_airdrop_pending_record.py} -{token_cancel_airdrop_transaction.py -> token_airdrop_transaction_cancel.py} + {pending_airdrop_id.py -> token_airdrop_pending_id.py} + {pending_airdrop_record.py -> token_airdrop_pending_record.py} + {token_cancel_airdrop_transaction.py -> token_airdrop_transaction_cancel.py} - In `TokenAirdropTransaction` the parameters of the following methods have been renamed: - add_nft_transfer(sender → sender_id, receiver → receiver_id) @@ -105,7 +104,6 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - chore: fix the examples workflow to log error messages and run on import failure (#738) - Added `docs/discord.md` explaining how to join and navigate the Hiero community Discord (#614). - ### Changed - Added direct links to Python SDK channel in Linux Foundation Decentralized Trust Discord back in @@ -138,6 +136,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - Type hinting for `Topic` related transactions. ### Removed + - Remove deprecated camelCase alias support and `_DeprecatedAliasesMixin`; SDK now only exposes snake_case attributes for `NftId`, `TokenInfo`, and `TransactionReceipt`. (Issue #428) ## [0.1.6] - 2025-10-21 @@ -178,8 +177,6 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - Improved `CONTRIBUTING.md` by explaining the /docs folder structure and fixing broken hyperlinks.(#431) - Converted class in `token_nft_info.py` to dataclass for simplicity. - - ### Fixed - Incompatible Types assignment in token_transfer_list.py @@ -221,8 +218,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - AccountId support for ECDSA alias accounts - ContractId.to_evm_address() method for EVM compatibility - consumeLargeData() function in StatefulContract -- example script for Token Airdrop -- added variables directly in the example script to reduce the need for users to supply extra environment variables. +- Added variables directly in the example script to reduce the need for users to supply extra environment variables. - Added new `merge_conflicts.md` with detailed guidance on handling conflicts during rebase. - Type hinting to /tokens, /transaction, /query, /consensus - Linting to /tokens, /transaction, /query, /consensus @@ -289,7 +285,7 @@ contract_call_local_pb2.ContractLoginfo -> contract_types_pb2.ContractLoginfo - src/hiero_sdk_python/consensus/topic_message.py: from hiero_sdk_python import Timestamp → from hiero_sdk_python.timestamp import Timestamp - src/hiero_sdk_python/query/topic_message_query.py: from hiero_sdk_python import Client → from hiero_sdk_python.client.client import Client -- src/hiero_sdk_python/tokens/**init**.py: content removed. +- src/hiero_sdk_python/tokens/\_\_init.py\_\_: content removed. - src/hiero_sdk_python/tokens/token_info.py: from hiero_sdk_python.hapi.services.token_get_info_pb2 import TokenInfo as proto_TokenInfo → from hiero_sdk_python.hapi.services import token_get_info_pb2 - src/hiero_sdk_python/tokens/token_key_validation.py: from hiero_sdk_python.hapi.services → import basic_types_pb2 - src/hiero_sdk_python/tokens/token_kyc_status.py: from hiero_sdk_python.hapi.services.basic_types_pb2 import TokenKycStatus as proto_TokenKycStatus → from hiero_sdk_python.hapi.services import basic_types_pb2 diff --git a/examples/token_airdrop.py b/examples/token_airdrop.py index 61c52fc8..661bfc6d 100644 --- a/examples/token_airdrop.py +++ b/examples/token_airdrop.py @@ -12,11 +12,14 @@ TokenAirdropTransaction, TokenAssociateTransaction, TokenMintTransaction, + TokenNftInfoQuery, CryptoGetAccountBalanceQuery, TokenType, ResponseCode, - NftId + NftId, + TransactionRecordQuery ) + # Check the transaction record to verify the contents load_dotenv() network_name = os.getenv('NETWORK', 'testnet').lower() @@ -62,7 +65,7 @@ def create_account(client, operator_key): def create_token(client, operator_id, operator_key): """Create a fungible token""" - print("\nCreating a token...") + print("\nStep 1: Creating a fungible token (TKA)...") try: token_tx = ( TokenCreateTransaction() @@ -84,7 +87,7 @@ def create_token(client, operator_id, operator_key): def create_nft(client, operator_key, operator_id): """Create a NFT""" - print("\nCreating a nft...") + print("\nStep 2: Creating a non-fungible token (NFTA)...") try: nft_tx = ( TokenCreateTransaction() @@ -106,7 +109,7 @@ def create_nft(client, operator_key, operator_id): def mint_nft(client, operator_key, nft_id): """Mint the NFT with metadata""" - print("\nMinting a nft...") + print("\nStep 3: Minting an NFT for NFTA...") try: mint_tx = TokenMintTransaction(token_id=nft_id, metadata=[b"NFT data"]) mint_tx.freeze_with(client) @@ -122,25 +125,29 @@ def mint_nft(client, operator_key, nft_id): def associate_tokens(client, recipient_id, recipient_key, tokens): """Associate the token and nft with the recipient""" - print("\nAssociating tokens to recipient...") + print("\nStep 4: Associating tokens to recipient...") try: - assocciate_tx = TokenAssociateTransaction( + associate_tx = TokenAssociateTransaction( account_id=recipient_id, token_ids=tokens ) - assocciate_tx.freeze_with(client) - assocciate_tx.sign(recipient_key) - assocciate_tx.execute(client) + associate_tx.freeze_with(client) + associate_tx.sign(recipient_key) + associate_receipt = associate_tx.execute(client) + + # Verify association was successful + if associate_receipt.status != ResponseCode.SUCCESS: + print(f"❌ Failed to associate tokens: Status: {associate_receipt.status}") + sys.exit(1) balance_before = ( CryptoGetAccountBalanceQuery(account_id=recipient_id) .execute(client) .token_balances ) - print("Tokens associated with recipient:") - print(f"{tokens[0]}: {balance_before.get(tokens[0])}") - print(f"{tokens[1]}: {balance_before.get(tokens[1])}") - + print("Tokens associated with recipient (should be 0 for both):") + print(f" {tokens[0]}: {balance_before.get(tokens[0], 0)}") + print(f" {tokens[1]}: {balance_before.get(tokens[1], 0)}") print("\n✅ Success! Token association complete.") except Exception as e: @@ -165,14 +172,28 @@ def token_airdrop(): # Create a nft nft_id = create_nft(client, operator_key, operator_id) - #Mint nft + # Mint nft serial_number = mint_nft(client, operator_key, nft_id) + print(f"Using NFT with serial #{serial_number} for the airdrop") # Associate tokens associate_tokens(client, recipient_id, recipient_key, [token_id, nft_id]) - # Airdrop Tthe tokens - print("\nAirdropping tokens...") + # Log balances before airdrop + print("\nStep 5: Checking balances before airdrop...") + sender_balances_before = CryptoGetAccountBalanceQuery(account_id=operator_id).execute(client).token_balances + recipient_balances_before = CryptoGetAccountBalanceQuery(account_id=recipient_id).execute(client).token_balances + print(f"Sender ({operator_id}) balances before airdrop:") + print(f" {token_id}: {sender_balances_before.get(token_id, 0)}") + print(f" {nft_id}: {sender_balances_before.get(nft_id, 0)}") + print(f"Recipient ({recipient_id}) balances before airdrop:") + print(f" {token_id}: {recipient_balances_before.get(token_id, 0)}") + print(f" {nft_id}: {recipient_balances_before.get(nft_id, 0)}") + + # Airdrop the tokens + print(f"\nStep 6: Airdropping tokens to recipient {recipient_id}:") + print(f" - 1 fungible token TKA ({token_id})") + print(f" - NFT from NFTA collection ({nft_id}) with serial number #{serial_number}") try: airdrop_receipt = ( TokenAirdropTransaction() @@ -188,20 +209,119 @@ def token_airdrop(): ) if airdrop_receipt.status != ResponseCode.SUCCESS: - print(f"Fail to cancel airdrop: Status: {airdrop_receipt.status}") + print(f"❌ Fail to airdrop: Status: {airdrop_receipt.status}") sys.exit(1) print(f"Token airdrop ID: {airdrop_receipt.transaction_id}") - - after_balance = ( - CryptoGetAccountBalanceQuery(account_id=recipient_id) - .execute(client) - .token_balances - ) - print("Recipient balance after token airdrop:") - print(f"{token_id}: {after_balance.get(token_id)}") - print(f"{nft_id}: {after_balance.get(nft_id)}") - + print("\nVerifying airdrop contents:") + print(f" - Transaction status: {airdrop_receipt.status}") + + # Fetch the transaction record and combine three verification sources into one step: + # 1) record.nft_transfers -> exact NFT serial transfers + # 2) balance deltas -> confirm ownership moved + # 3) TokenNftInfoQuery -> confirm current owner for the serial + record = TransactionRecordQuery(transaction_id=airdrop_receipt.transaction_id).execute(client) + + print(" - Token transfers in this transaction:") + expected_token_transfer = False + for token_id_key, transfers in record.token_transfers.items(): + is_expected_token = token_id_key == token_id + token_indicator = "✓ EXPECTED" if is_expected_token else "" + print(f" Token {token_id_key}: {token_indicator}") + # Check for the expected transfer pattern (operator -> recipient) + sender_sent_one = False + recipient_received_one = False + for account, amount in transfers.items(): + if amount > 0: + print(f" → {account} received {amount} token(s)") + if account == recipient_id and amount == 1 and is_expected_token: + recipient_received_one = True + else: + print(f" → {account} sent {abs(amount)} token(s)") + if account == operator_id and amount == -1 and is_expected_token: + sender_sent_one = True + if sender_sent_one and recipient_received_one: + expected_token_transfer = True + + print("\n - NFT transfers recorded in this transaction:") + nft_transfer_confirmed = False + nft_serials_transferred = [] + # record.nft_transfers is expected to be a mapping token_id -> list of nft transfer entries + try: + # record.nft_transfers holds TokenId -> list[TokenNftTransfer] + for token_key, nft_transfers in record.nft_transfers.items(): + print(f" NFT Token: {token_key}") + for nft_transfer in nft_transfers: + # nft_transfer is a TokenNftTransfer instance + sender = getattr(nft_transfer, 'sender_id', None) + receiver = getattr(nft_transfer, 'receiver_id', None) + serial = getattr(nft_transfer, 'serial_number', None) + print(f" → serial #{serial}: {sender} -> {receiver}") + nft_serials_transferred.append((token_key, serial, sender, receiver)) + # Confirm the exact serial was moved from operator -> recipient + if token_key == nft_id and sender == operator_id and receiver == recipient_id and serial == serial_number: + nft_transfer_confirmed = True + except Exception as ex: + # If unexpected structure, print for debugging + print(f" (Error parsing record.nft_transfers: {ex})") + + # Now validate balances and TokenNftInfoQuery for the specific serial + try: + sender_current = CryptoGetAccountBalanceQuery(account_id=operator_id).execute(client).token_balances + recipient_current = CryptoGetAccountBalanceQuery(account_id=recipient_id).execute(client).token_balances + + sender_nft_before = sender_balances_before.get(nft_id, 0) + sender_nft_after = sender_current.get(nft_id, 0) + recipient_nft_before = recipient_balances_before.get(nft_id, 0) + recipient_nft_after = recipient_current.get(nft_id, 0) + + print(f"\n - NFT balance changes: sender {sender_nft_before} -> {sender_nft_after}, recipient {recipient_nft_before} -> {recipient_nft_after}") + + # Query NFT info for the serial to confirm ownership + nft_serial_id = NftId(token_id=nft_id, serial_number=serial_number) + nft_info = TokenNftInfoQuery(nft_id=nft_serial_id).execute(client) + nft_owner = getattr(nft_info, 'account_id', None) + + owner_matches = nft_owner == recipient_id + + # Combine all checks + fully_verified = nft_transfer_confirmed and (sender_nft_after < sender_nft_before) and (recipient_nft_after > recipient_nft_before) and owner_matches + + print(f" - Token transfer verification (fungible): {'OK' if expected_token_transfer else 'MISSING'}") + print(f" - NFT transfer seen in record: {'OK' if nft_transfer_confirmed else 'MISSING'}") + print(f" - NFT owner according to TokenNftInfoQuery: {nft_owner}") + print(f" - NFT owner matches recipient: {'YES' if owner_matches else 'NO'}") + + if fully_verified: + print(f"\n ✅ Success! NFT {nft_id} serial #{serial_number} was transferred from {operator_id} to {recipient_id} and verified by record + balances + TokenNftInfoQuery") + else: + print(f"\n ⚠️ Warning: Could not fully verify NFT {nft_id} serial #{serial_number}. Combined checks result: {fully_verified}") + except Exception as e: + print(f" Error during combined NFT verification: {e}") + + # Save balances after airdrop for later display + sender_balances_after = CryptoGetAccountBalanceQuery(account_id=operator_id).execute(client).token_balances + recipient_balances_after = CryptoGetAccountBalanceQuery(account_id=recipient_id).execute(client).token_balances + + # Log balances after airdrop + sender_balances_after = CryptoGetAccountBalanceQuery(account_id=operator_id).execute(client).token_balances + recipient_balances_after = CryptoGetAccountBalanceQuery(account_id=recipient_id).execute(client).token_balances + print("\nBalances after airdrop:") + print(f"Sender ({operator_id}):") + print(f" {token_id}: {sender_balances_after.get(token_id, 0)}") + print(f" {nft_id}: {sender_balances_after.get(nft_id, 0)}") + print(f"Recipient ({recipient_id}):") + print(f" {token_id}: {recipient_balances_after.get(token_id, 0)}") + print(f" {nft_id}: {recipient_balances_after.get(nft_id, 0)}") + + # Summary table + print("\nSummary Table:") + print("+----------------+----------------------+----------------------+----------------------+----------------------+") + print("| Token Type | Token ID | NFT Serial | Sender Balance | Recipient Balance |") + print("+----------------+----------------------+----------------------+----------------------+----------------------+") + print(f"| Fungible (TKA) | {str(token_id):20} | {'N/A':20} | {str(sender_balances_after.get(token_id, 0)):20} | {str(recipient_balances_after.get(token_id, 0)):20} |") + print(f"| NFT (NFTA) | {str(nft_id):20} | #{str(serial_number):19} | {str(sender_balances_after.get(nft_id, 0)):20} | {str(recipient_balances_after.get(nft_id, 0)):20} |") + print("+----------------+----------------------+----------------------+----------------------+----------------------+") print("\n✅ Success! Token Airdrop transaction successful") except Exception as e: print(f"❌ Error airdropping tokens: {e}") @@ -209,3 +329,4 @@ def token_airdrop(): if __name__ == "__main__": token_airdrop() + diff --git a/examples/token_airdrop_cancel.py b/examples/token_airdrop_cancel.py index 8a484f37..b8cba3cf 100644 --- a/examples/token_airdrop_cancel.py +++ b/examples/token_airdrop_cancel.py @@ -78,14 +78,43 @@ def create_token(client, operator_id, operator_key, token_name, token_symbol, in token_id = receipt.token_id print(f"Created token {token_name} with ID: {token_id}") return token_id + # Create two new tokens. + print("\nStep 1: Creating two new fungible tokens...") + try: + tx1 = TokenCreateTransaction().set_token_name("First Token").set_token_symbol("TKA").set_initial_supply(1).set_treasury_account_id(operator_id) + receipt1 = tx1.freeze_with(client).sign(operator_key).execute(client) + token_id_1 = receipt1.token_id + + tx2 = TokenCreateTransaction().set_token_name("Second Token").set_token_symbol("TKB").set_initial_supply(1).set_treasury_account_id(operator_id) + receipt2 = tx2.freeze_with(client).sign(operator_key).execute(client) + token_id_2 = receipt2.token_id + + print(f"✅ Created tokens: {token_id_1} (TKA) and {token_id_2} (TKB)") except Exception as e: print(f"Error creating token {token_name}: {e}") sys.exit(1) + # Log balances before airdrop + print("\nStep 2: Checking balances before airdrop...") + from hiero_sdk_python import CryptoGetAccountBalanceQuery + sender_balances_before = CryptoGetAccountBalanceQuery(account_id=operator_id).execute(client).token_balances + + recipient_balances_before = CryptoGetAccountBalanceQuery(account_id=recipient_id).execute(client).token_balances + print(f"Sender ({operator_id}) balances before airdrop:") + print(f" {str(token_id_1)}: {sender_balances_before.get(str(token_id_1), 0)}") + + print(f" {str(token_id_2)}: {sender_balances_before.get(str(token_id_2), 0)}") + + print(f"Recipient ({recipient_id}) balances before airdrop:") + + print(f" {str(token_id_1)}: {recipient_balances_before.get(str(token_id_1), 0)}") + + print(f" {str(token_id_2)}: {recipient_balances_before.get(str(token_id_2), 0)}") def airdrop_tokens(client, operator_id, operator_key, recipient_id, token_ids): """Airdrop the provided tokens to a recipient account.""" - print("\nAirdropping tokens...") + print(f"\nStep 3: Airdroppingping tokens TKA ({token_id_1}) and TKB ({token_id_2}) to recipient {recipient_id}...") + try: tx = TokenAirdropTransaction() for token_id in token_ids: @@ -97,6 +126,42 @@ def airdrop_tokens(client, operator_id, operator_key, recipient_id, token_ids): airdrop_record = TransactionRecordQuery(receipt.transaction_id).execute(client) return airdrop_record.new_pending_airdrops + # Log balances after airdrop + sender_balances_after = CryptoGetAccountBalanceQuery(account_id=operator_id).execute(client).token_balances + + recipient_balances_after = CryptoGetAccountBalanceQuery(account_id=recipient_id).execute(client).token_balances + print("\nBalances after airdrop:") + + print(f"Sender ({operator_id}):") + + print(f" {str(token_id_1)}: {sender_balances_after.get(str(token_id_1), 0)}") + + print(f" {str(token_id_2)}: {sender_balances_after.get(str(token_id_2), 0)}") + + print(f"Recipient ({recipient_id}):") + + print(f" {str(token_id_1)}: {recipient_balances_after.get(str(token_id_1), 0)}") + + print(f" {str(token_id_2)}: {recipient_balances_after.get(str(token_id_2), 0)}") + + + + # Summary table + + print("\nSummary Table:") + + print("+----------------+----------------------+----------------------+----------------------+\n" + + "| Token Symbol | Token ID | Sender Balance | Recipient Balance |\n" + + "+----------------+----------------------+----------------------+----------------------+") + + print(f"| TKA | {str(token_id_1):<20} | {str(sender_balances_after.get(str(token_id_1), 0)):<20} | {str(recipient_balances_after.get(str(token_id_1), 0)):<20} |") + + print(f"| TKB | {str(token_id_2):<20} | {str(sender_balances_after.get(str(token_id_2), 0)):<20} | {str(recipient_balances_after.get(str(token_id_2), 0)):<20} |") + + print("+----------------+----------------------+----------------------+----------------------+") + except Exception as e: print(f"Error airdropping tokens: {e}") sys.exit(1)