From 6094ed3bd55f804f4929089da488192eba8132c5 Mon Sep 17 00:00:00 2001 From: emiliyank Date: Mon, 1 Dec 2025 20:47:28 +0200 Subject: [PATCH] add missing fields for AccountUpdateTransaction Signed-off-by: emiliyank --- .github/scripts/changelog_check.sh | 127 ++++++++ .github/workflows/bot-office-hours.yml | 73 +++++ .github/workflows/bot-verified-commits.yml | 18 +- .github/workflows/merge-conflict-bot.yml | 71 ++++ .github/workflows/pr-check-changelog.yml | 27 ++ .github/workflows/pr-check-title.yml | 41 +++ .github/workflows/pr-checks.yml | 97 ------ CHANGELOG.md | 16 +- examples/transaction/custom_fee_limit.py | 144 +++++++++ .../account/account_update_transaction.py | 154 ++++++++- .../transaction/transaction.py | 2 +- .../account_update_transaction_e2e_test.py | 137 +++++++- tests/unit/test_account_update_transaction.py | 303 +++++++++++++++++- 13 files changed, 1102 insertions(+), 108 deletions(-) create mode 100755 .github/scripts/changelog_check.sh create mode 100644 .github/workflows/bot-office-hours.yml create mode 100644 .github/workflows/merge-conflict-bot.yml create mode 100644 .github/workflows/pr-check-changelog.yml create mode 100644 .github/workflows/pr-check-title.yml delete mode 100644 .github/workflows/pr-checks.yml create mode 100644 examples/transaction/custom_fee_limit.py diff --git a/.github/scripts/changelog_check.sh b/.github/scripts/changelog_check.sh new file mode 100755 index 000000000..e3e77e650 --- /dev/null +++ b/.github/scripts/changelog_check.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +CHANGELOG="CHANGELOG.md" + +# ANSI color codes +RED="\033[31m" +GREEN="\033[32m" +YELLOW="\033[33m" +RESET="\033[0m" + +failed=0 + +# Fetch upstream +git remote add upstream https://github.com/${GITHUB_REPOSITORY}.git +git fetch upstream main >/dev/null 2>&1 + +# Get raw diff +raw_diff=$(git diff upstream/main -- "$CHANGELOG") + +# 1️⃣ Show raw diff with colors +echo "=== Raw git diff of $CHANGELOG against upstream/main ===" +while IFS= read -r line; do + if [[ $line =~ ^\+ && ! $line =~ ^\+\+\+ ]]; then + echo -e "${GREEN}$line${RESET}" + elif [[ $line =~ ^- && ! $line =~ ^--- ]]; then + echo -e "${RED}$line${RESET}" + else + echo "$line" + fi +done <<< "$raw_diff" +echo "=================================" + +# 2️⃣ Extract added bullet lines +added_bullets=() +while IFS= read -r line; do + [[ -n "$line" ]] && added_bullets+=("$line") +done < <(echo "$raw_diff" | sed -n 's/^+//p' | grep -E '^[[:space:]]*[-*]' | sed '/^[[:space:]]*$/d') + +# 2️⃣a Extract deleted bullet lines +deleted_bullets=() +while IFS= read -r line; do + [[ -n "$line" ]] && deleted_bullets+=("$line") +done < <(echo "$raw_diff" | grep '^\-' | grep -vE '^(--- |\+\+\+ |@@ )' | sed 's/^-//') + +# 2️⃣b Warn if no added entries +if [[ ${#added_bullets[@]} -eq 0 ]]; then + echo -e "${RED}❌ No new changelog entries detected in this PR.${RESET}" + echo -e "${YELLOW}⚠️ Please add an entry in [UNRELEASED] under the appropriate subheading.${RESET}" + failed=1 +fi + +# 3️⃣ Initialize results +correctly_placed="" +orphan_entries="" +wrong_release_entries="" + +# 4️⃣ Walk through changelog to classify entries +current_release="" +current_subtitle="" +in_unreleased=0 + +while IFS= read -r line; do + # Track release sections + if [[ $line =~ ^##\ \[Unreleased\] ]]; then + current_release="Unreleased" + in_unreleased=1 + current_subtitle="" + continue + elif [[ $line =~ ^##\ \[.*\] ]]; then + current_release="$line" + in_unreleased=0 + current_subtitle="" + continue + elif [[ $line =~ ^### ]]; then + current_subtitle="$line" + continue + fi + + # Check each added bullet + for added in "${added_bullets[@]}"; do + if [[ "$line" == "$added" ]]; then + if [[ "$in_unreleased" -eq 1 && -n "$current_subtitle" ]]; then + correctly_placed+="$added (placed under $current_subtitle)"$'\n' + elif [[ "$in_unreleased" -eq 1 && -z "$current_subtitle" ]]; then + orphan_entries+="$added (NOT under a subtitle)"$'\n' + elif [[ "$in_unreleased" -eq 0 ]]; then + wrong_release_entries+="$added (added under released version $current_release)"$'\n' + fi + fi + done +done < "$CHANGELOG" + +# 5️⃣ Display results +if [[ -n "$orphan_entries" ]]; then + echo -e "${RED}❌ Some CHANGELOG entries are not under a subtitle in [Unreleased]:${RESET}" + echo "$orphan_entries" + failed=1 +fi + +if [[ -n "$wrong_release_entries" ]]; then + echo -e "${RED}❌ Some changelog entries were added under a released version (should be in [Unreleased]):${RESET}" + echo "$wrong_release_entries" + failed=1 +fi + +if [[ -n "$correctly_placed" ]]; then + echo -e "${GREEN}✅ Some CHANGELOG entries are correctly placed under [Unreleased]:${RESET}" + echo "$correctly_placed" +fi + +# 6️⃣ Display deleted entries +if [[ ${#deleted_bullets[@]} -gt 0 ]]; then + echo -e "${RED}❌ Changelog entries removed in this PR:${RESET}" + for deleted in "${deleted_bullets[@]}"; do + echo -e " - ${RED}$deleted${RESET}" + done + echo -e "${YELLOW}⚠️ Please add these entries back under the appropriate sections${RESET}" +fi + +# 7️⃣ Exit with failure if any bad entries exist +if [[ $failed -eq 1 ]]; then + echo -e "${RED}❌ Changelog check failed.${RESET}" + exit 1 +else + echo -e "${GREEN}✅ Changelog check passed.${RESET}" + exit 0 +fi diff --git a/.github/workflows/bot-office-hours.yml b/.github/workflows/bot-office-hours.yml new file mode 100644 index 000000000..c3172594b --- /dev/null +++ b/.github/workflows/bot-office-hours.yml @@ -0,0 +1,73 @@ +name: PythonBot - Office Hour Reminder + +on: + # push: + schedule: + - cron: '0 10 * * 3' + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + office-hour-reminder: + runs-on: ubuntu-latest + steps: + - name: Harden the runner + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 + with: + egress-policy: audit + + - name: Check Schedule and Notify + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ANCHOR_DATE="2025-12-03" + MEETING_LINK="https://zoom-lfx.platform.linuxfoundation.org/meeting/99912667426?password=5b584a0e-1ed7-49d3-b2fc-dc5ddc888338" + CALENDAR_LINK="https://zoom-lfx.platform.linuxfoundation.org/meetings/hiero?view=week" + + IS_MEETING_WEEK=$(python3 -c "from datetime import date; import os; d1=date.fromisoformat('$ANCHOR_DATE'); d2=date.today(); print('true' if (d2-d1).days % 14 == 0 else 'false')") + + if [ "$IS_MEETING_WEEK" = "false" ]; then + echo "Not a fortnightly meeting week. Skipping execution." + exit 0 + fi + + echo "Meeting week detected. Proceeding to notify open PRs." + + REPO="${{ github.repository }}" + PR_LIST=$(gh pr list --repo $REPO --state open --json number --jq '.[].number') + + if [ -z "$PR_LIST" ]; then + echo "No open PRs found." + exit 0 + fi + + COMMENT_BODY=$(cat </dev/null || \ - (gh pr comment $PR_NUMBER --repo $REPO --body "$COMMENT" && echo "Comment added to PR #$PR_NUMBER") + gh pr comment $PR_NUMBER --repo $REPO --body "$COMMENT" + echo "Comment added to PR #$PR_NUMBER" + fi + + exit 1 else echo "All commits in PR #$PR_NUMBER are verified." fi - \ No newline at end of file diff --git a/.github/workflows/merge-conflict-bot.yml b/.github/workflows/merge-conflict-bot.yml new file mode 100644 index 000000000..56ae98ed9 --- /dev/null +++ b/.github/workflows/merge-conflict-bot.yml @@ -0,0 +1,71 @@ +name: PythonBot - Check Merge Conflicts + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: "check-conflicts-${{ github.event.pull_request.number }}" + cancel-in-progress: true + +jobs: + check-conflicts: + runs-on: ubuntu-latest + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + with: + egress-policy: audit + + - name: Check for merge conflicts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=${{ github.event.pull_request.number }} + REPO="${{ github.repository }}" + + echo "Checking merge status for PR #$PR_NUMBER in repository $REPO..." + + for i in {1..10}; do + PR_JSON=$(gh api repos/$REPO/pulls/$PR_NUMBER) + MERGEABLE_STATE=$(echo "$PR_JSON" | jq -r '.mergeable_state') + + echo "Attempt $i: Current mergeable state: $MERGEABLE_STATE" + + if [ "$MERGEABLE_STATE" != "unknown" ]; then + break + fi + + echo "State is 'unknown', waiting 2 seconds..." + sleep 2 + done + + if [ "$MERGEABLE_STATE" = "dirty" ]; then + COMMENT=$(cat </dev/null || \ + (gh pr comment $PR_NUMBER --repo $REPO --body "$COMMENT" && echo "Comment added to PR #$PR_NUMBER") + + exit 1 + else + echo "No merge conflicts detected (State: $MERGEABLE_STATE)." + fi diff --git a/.github/workflows/pr-check-changelog.yml b/.github/workflows/pr-check-changelog.yml new file mode 100644 index 000000000..603ad9ec7 --- /dev/null +++ b/.github/workflows/pr-check-changelog.yml @@ -0,0 +1,27 @@ +name: 'PR Changelog Check' + +on: + workflow_dispatch: + pull_request: + types: [opened, reopened, edited, synchronize] + +permissions: + contents: read + +jobs: + changelog-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + fetch-depth: 0 + + - name: Harden the runner + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + with: + egress-policy: audit + + - name: Run local changelog check + run: | + chmod +x .github/scripts/changelog_check.sh + bash .github/scripts/changelog_check.sh \ No newline at end of file diff --git a/.github/workflows/pr-check-title.yml b/.github/workflows/pr-check-title.yml new file mode 100644 index 000000000..897fa6348 --- /dev/null +++ b/.github/workflows/pr-check-title.yml @@ -0,0 +1,41 @@ +name: 'PR Formatting' +on: + workflow_dispatch: + pull_request_target: + types: + - opened + - reopened + - edited + - synchronize + +defaults: + run: + shell: bash + +permissions: + contents: read + checks: write + statuses: write + +concurrency: + group: pr-checks-${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + title-check: + name: Title Check + runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.base.repo.fork }} + permissions: + checks: write + statuses: write + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + with: + egress-policy: audit + + - name: Check PR Title + uses: step-security/conventional-pr-title-action@cb1c5657ccf4c42f5c0a6c0708cb8251b960d902 # v3.2.5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml deleted file mode 100644 index 4aa042ae2..000000000 --- a/.github/workflows/pr-checks.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: 'PR Formatting' -on: - workflow_dispatch: - pull_request_target: - types: - - opened - - reopened - - edited - - synchronize - -defaults: - run: - shell: bash - -permissions: - contents: read - checks: write - statuses: write - -concurrency: - group: pr-checks-${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - title-check: - name: Title Check - runs-on: ubuntu-latest - if: ${{ !github.event.pull_request.base.repo.fork }} - permissions: - checks: write - statuses: write - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 - with: - egress-policy: audit - - - name: Check PR Title - uses: step-security/conventional-pr-title-action@cb1c5657ccf4c42f5c0a6c0708cb8251b960d902 # v3.2.5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - changelog-check: - name: Changelog Check - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 - with: - egress-policy: audit - - - name: Validate CHANGELOG.md has changes - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - - if [[ "${{ github.event_name }}" != "pull_request_target" && "${{ github.event_name }}" != "pull_request" ]]; then - echo "Not a pull request event. Skipping." - exit 0 - fi - - PR_NUMBER="${{ github.event.pull_request.number }}" - if [[ -z "${PR_NUMBER}" ]]; then - echo "Unable to determine PR number." - exit 1 - fi - - echo "Checking files changed in PR #${PR_NUMBER}..." - - # List files in the PR and check for CHANGELOG.md (root or any path ending with /CHANGELOG.md) - FILES_JSON="$(gh api repos/${{ github.repository }}/pulls/${PR_NUMBER}/files --paginate)" - if ! echo "${FILES_JSON}" | jq -e '.[] | select(.filename | test("(^|/)CHANGELOG\\.md$"; "i"))' >/dev/null; then - echo "FAIL: CHANGELOG.md was not changed in this PR." - exit 1 - fi - - # Ensure there is at least one line-level change (+ or -) in the patch for CHANGELOG.md - PATCH_CONTENT="$(echo "${FILES_JSON}" \ - | jq -r '.[] | select(.filename | test("(^|/)CHANGELOG\\.md$"; "i")) | .patch // empty')" - - # If no patch is returned (very large file or API omission), treat presence as sufficient - if [[ -z "${PATCH_CONTENT}" ]]; then - echo "CHANGELOG.md modified (no patch provided by API). Passing." - exit 0 - fi - - # Look for actual line changes (exclude diff headers +++/---) - if echo "${PATCH_CONTENT}" | awk '{print $0}' | grep -E '^[+-]' | grep -vE '^\+\+\+|^\-\-\-' >/dev/null; then - echo "PASS: CHANGELOG.md contains line-level changes." - exit 0 - else - echo "FAIL: No line-level changes detected in CHANGELOG.md." - exit 1 - fi diff --git a/CHANGELOG.md b/CHANGELOG.md index d5518da11..dd445cc9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,24 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. ## [Unreleased] +### Added +- Add `max_automatic_token_associations`, `staked_account_id`, `staked_node_id` and `decline_staking_reward` fields to `AccountUpdateTransaction` (#801) +- Add example demonstrating usage of `CustomFeeLimit` in `examples/transaction/custom_fee_limit.py` +- Added `.github/workflows/merge-conflict-bot.yml` to automatically detect and notify users of merge conflicts in Pull Requests. +- Added validation logic in `.github/workflows/pr-checks.yml` to detect when no new chnagelog entries are added under [Unreleased] + +### Changed + +- Removed duplicate import of transaction_pb2 in transaction.py + +### Fixed +- fixed workflow: changelog check with improved sensitivity to deletions, additions, new releases + ## [0.1.9] - 2025-11-26 ### Added +- Add a limit of one comment for PR to the commit verification bot. [#892] - Removed `actions/checkout@v4` from `bot-verified-commits.yml` - Add comprehensive documentation for `ReceiptStatusError` in `docs/sdk_developers/training/receipt_status_error.md` - Add practical example `examples/errors/receipt_status_error.py` demonstrating transaction error handling @@ -33,12 +47,12 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - EvmAddress class - `alias`, `staked_account_id`, `staked_node_id` and `decline_staking_reward` fields to AccountCreateTransaction - `staked_account_id`, `staked_node_id` and `decline_staking_reward` fields to AccountInfo -- Added `examples/token_create_transaction_supply_key.py` to demonstrate token creation with and without a supply key. - Added `examples/token_create_transaction_kyc_key.py` to demonstrate KYC key functionality, including creating tokens with/without KYC keys, granting/revoking KYC status, and understanding KYC requirements for token transfers. - Add `set_token_ids`, `_from_proto`, `_validate_checksum` to TokenAssociateTransaction [#795] - Added BatchTransaction class - Add support for token metadata (bytes, max 100 bytes) in `TokenCreateTransaction`, including a new `set_metadata` setter, example, and tests. [#799] - Added `examples/token_create_transaction_token_fee_schedule.py` to demonstrate creating tokens with custom fee schedules and the consequences of not having it. +- Added `examples/token_create_transaction_supply_key.py` to demonstrate token creation with and without a supply key. - Added `examples/token_create_transaction_wipe_key.py` to demonstrate token wiping and the role of the wipe key. - Added `examples/account_allowance_approve_transaction_hbar.py` and `examples/account_allowance_delete_transaction_hbar.py`, deleted `examples/account_allowance_hbar.py`. [#775] - Added `docs\sdk_developers\training\receipts.md` as a training guide for users to understand hedera receipts. diff --git a/examples/transaction/custom_fee_limit.py b/examples/transaction/custom_fee_limit.py new file mode 100644 index 000000000..a7250cb0b --- /dev/null +++ b/examples/transaction/custom_fee_limit.py @@ -0,0 +1,144 @@ +""" +Example: Using CustomFeeLimit with a revenue-generating topic. + +- Creates a topic that charges a fixed custom fee per message. +- Submits a message with a CustomFeeLimit specifying how much the payer is + willing to pay in custom fees for that message. +""" + +import os +import sys +from dotenv import load_dotenv + +from hiero_sdk_python import ( + Client, + AccountId, + PrivateKey, + Hbar, + Network, + TopicCreateTransaction, + TopicMessageSubmitTransaction, + CustomFixedFee, +) +from hiero_sdk_python.transaction.custom_fee_limit import CustomFeeLimit + + +def setup_client() -> tuple[Client, AccountId]: + """Initialize client and operator from .env file.""" + load_dotenv() + + if "OPERATOR_ID" not in os.environ or "OPERATOR_KEY" not in os.environ: + print("Environment variables OPERATOR_ID or OPERATOR_KEY are missing.") + sys.exit(1) + + try: + operator_id = AccountId.from_string(os.environ["OPERATOR_ID"]) + operator_key = PrivateKey.from_string(os.environ["OPERATOR_KEY"]) + except Exception as e: # noqa: BLE001 + print(f"Failed to parse OPERATOR_ID or OPERATOR_KEY: {e}") + sys.exit(1) + + network_name = os.environ.get("NETWORK", "testnet") + + try: + client = Client(Network(network_name)) + except Exception as e: + print(f"Failed to create client for network '{network_name}': {e}") + sys.exit(1) + + client.set_operator(operator_id, operator_key) + print(f"Operator set: {operator_id}") + + return client, operator_id + + +def create_revenue_generating_topic(client: Client, operator_id: AccountId): + """ + Create a topic that charges a fixed custom fee per message. + + The topic charges 1 HBAR (in tinybars) to the operator account for every message. + """ + print("\nCreating a topic with a fixed custom fee per message...") + + # Charge 1 HBAR to the operator for every message + custom_fee = CustomFixedFee( + amount=Hbar(1).to_tinybars(), + fee_collector_account_id=operator_id, + ) + + try: + topic_tx = TopicCreateTransaction() + topic_tx.set_custom_fees([custom_fee]) + + # execute() returns the receipt + topic_receipt = topic_tx.execute(client) + + topic_id = topic_receipt.topic_id + print(f"Topic created successfully: {topic_id}") + print("This topic charges a fixed fee of 1 HBAR per message.") + + return topic_id + except Exception as e: # noqa: BLE001 + print(f"Failed to create topic: {e}") + return None + + +def submit_message_with_custom_fee_limit( + client: Client, topic_id, operator_id: AccountId +) -> None: + """ + Submit a message to the topic with a CustomFeeLimit applied. + + The CustomFeeLimit caps the total custom fees the payer is willing to pay + for this message at 2 HBAR. + """ + print("\nSubmitting a message with a CustomFeeLimit...") + + # We are willing to pay up to 2 HBAR in custom fees for this message + limit_fee = CustomFixedFee( + amount=Hbar(2).to_tinybars(), + fee_collector_account_id=operator_id, + ) + + fee_limit = CustomFeeLimit() + fee_limit.set_payer_id(operator_id) + fee_limit.add_custom_fee(limit_fee) + + print( + f"Setting fee limit: max {limit_fee.amount} tinybars " + f"in custom fees for payer {operator_id}" + ) + + try: + submit_tx = TopicMessageSubmitTransaction() + submit_tx.set_topic_id(topic_id) + submit_tx.set_message("Hello Hedera with Fee Limits!") + + # Ensure the base transaction fee is high enough to cover processing + submit_tx.transaction_fee = Hbar(5).to_tinybars() + + # Attach the custom fee limit to the transaction + submit_tx.set_custom_fee_limits([fee_limit]) + + submit_receipt = submit_tx.execute(client) + + print("Message submitted successfully!") + print(f"Transaction status: {submit_receipt.status}") + except Exception as e: # noqa: BLE001 + print(f"Transaction failed: {e}") + + +def main() -> None: + client, operator_id = setup_client() + + topic_id = create_revenue_generating_topic(client, operator_id) + if topic_id is None: + return + + submit_message_with_custom_fee_limit(client, topic_id, operator_id) + + print("\nExample complete.") + + +if __name__ == "__main__": + main() diff --git a/src/hiero_sdk_python/account/account_update_transaction.py b/src/hiero_sdk_python/account/account_update_transaction.py index c84d08e6a..b2485dce0 100644 --- a/src/hiero_sdk_python/account/account_update_transaction.py +++ b/src/hiero_sdk_python/account/account_update_transaction.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Optional -from google.protobuf.wrappers_pb2 import BoolValue, StringValue +from google.protobuf.wrappers_pb2 import BoolValue, Int32Value, StringValue from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.channels import _Channel @@ -35,6 +35,14 @@ class AccountUpdateParams: account_memo (Optional[str]): The new memo for the account. receiver_signature_required (Optional[bool]): Whether receiver signature is required. expiration_time (Optional[Timestamp]): The new expiration time for the account. + max_automatic_token_associations (Optional[int]): The maximum number of tokens that + can be auto-associated with this account. Use -1 for unlimited, 0 for none. + staked_account_id (Optional[AccountId]): The account to which this account is staking + its balances. Mutually exclusive with staked_node_id. + staked_node_id (Optional[int]): The node ID to which this account is staking + its balances. Mutually exclusive with staked_account_id. + decline_staking_reward (Optional[bool]): If true, the account declines receiving + staking rewards. """ account_id: Optional[AccountId] = None @@ -43,6 +51,10 @@ class AccountUpdateParams: account_memo: Optional[str] = None receiver_signature_required: Optional[bool] = None expiration_time: Optional[Timestamp] = None + max_automatic_token_associations: Optional[int] = None + staked_account_id: Optional[AccountId] = None + staked_node_id: Optional[int] = None + decline_staking_reward: Optional[bool] = None class AccountUpdateTransaction(Transaction): @@ -70,6 +82,10 @@ def __init__(self, account_params: Optional[AccountUpdateParams] = None): self.account_memo = params.account_memo self.receiver_signature_required = params.receiver_signature_required self.expiration_time = params.expiration_time + self.max_automatic_token_associations = params.max_automatic_token_associations + self.staked_account_id = params.staked_account_id + self.staked_node_id = params.staked_node_id + self.decline_staking_reward = params.decline_staking_reward def set_account_id(self, account_id: Optional[AccountId]) -> "AccountUpdateTransaction": """ @@ -161,6 +177,121 @@ def set_expiration_time( self.expiration_time = expiration_time return self + def set_max_automatic_token_associations( + self, max_automatic_token_associations: Optional[int] + ) -> "AccountUpdateTransaction": + """ + Sets the maximum number of tokens that can be auto-associated with this account. + + Args: + max_automatic_token_associations (Optional[int]): The maximum number of tokens + that can be auto-associated. Use -1 for unlimited, 0 for none. + Must be >= -1. + + Returns: + AccountUpdateTransaction: This transaction instance. + + Raises: + ValueError: If max_automatic_token_associations is less than -1. + """ + self._require_not_frozen() + if max_automatic_token_associations is not None and max_automatic_token_associations < -1: + raise ValueError( + "max_automatic_token_associations must be -1 (unlimited) or a non-negative integer." + ) + self.max_automatic_token_associations = max_automatic_token_associations + return self + + def set_staked_account_id( + self, staked_account_id: Optional[AccountId] + ) -> "AccountUpdateTransaction": + """ + Sets the account to which this account is staking its balances. + + This field is mutually exclusive with staked_node_id. Setting this will + clear any previously set staked_node_id. Passing ``None`` (or calling + :func:`clear_staked_account_id`) removes staking and sends the sentinel + AccountId (0.0.0) to the network. + + Args: + staked_account_id (Optional[AccountId]): The account to which this account + will stake its balances. ``None`` clears the staking configuration. + + Returns: + AccountUpdateTransaction: This transaction instance. + """ + self._require_not_frozen() + if staked_account_id is None: + return self.clear_staked_account_id() + self.staked_account_id = staked_account_id + self.staked_node_id = None # Clear the other field in the oneOf + return self + + def set_staked_node_id( + self, staked_node_id: Optional[int] + ) -> "AccountUpdateTransaction": + """ + Sets the node ID to which this account is staking its balances. + + This field is mutually exclusive with staked_account_id. Setting this will + clear any previously set staked_account_id. Passing ``None`` (or calling + :func:`clear_staked_node_id`) removes staking and sends the sentinel value (-1). + + Args: + staked_node_id (Optional[int]): The node ID to which this account will stake + its balances. ``None`` clears the staking configuration. + + Returns: + AccountUpdateTransaction: This transaction instance. + """ + self._require_not_frozen() + if staked_node_id is None: + return self.clear_staked_node_id() + self.staked_node_id = staked_node_id + self.staked_account_id = None # Clear the other field in the oneOf + return self + + def clear_staked_account_id(self) -> "AccountUpdateTransaction": + """ + Clears staking to an account by setting the sentinel AccountId (0.0.0). + + Returns: + AccountUpdateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.staked_account_id = AccountId(0, 0, 0) + self.staked_node_id = None + return self + + def clear_staked_node_id(self) -> "AccountUpdateTransaction": + """ + Clears staking to a node by setting the sentinel node ID (-1). + + Returns: + AccountUpdateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.staked_node_id = -1 + self.staked_account_id = None + return self + + def set_decline_staking_reward( + self, decline_staking_reward: Optional[bool] + ) -> "AccountUpdateTransaction": + """ + Sets whether the account declines receiving staking rewards. + + Args: + decline_staking_reward (Optional[bool]): If True, the account declines receiving + staking rewards. If False or None, the account will receive rewards. + + Returns: + AccountUpdateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.decline_staking_reward = decline_staking_reward + return self + def _build_proto_body(self): """ Returns the protobuf body for the account update transaction. @@ -174,7 +305,7 @@ def _build_proto_body(self): if self.account_id is None: raise ValueError("Missing required AccountID to update") - return CryptoUpdateTransactionBody( + proto_body = CryptoUpdateTransactionBody( accountIDToUpdate=self.account_id._to_proto(), key=self.key._to_proto() if self.key else None, memo=StringValue(value=self.account_memo) if self.account_memo is not None else None, @@ -187,8 +318,26 @@ def _build_proto_body(self): if self.receiver_signature_required is not None else None ), + max_automatic_token_associations=( + Int32Value(value=self.max_automatic_token_associations) + if self.max_automatic_token_associations is not None + else None + ), + decline_reward=( + BoolValue(value=self.decline_staking_reward) + if self.decline_staking_reward is not None + else None + ), ) + # Handle staked_id oneOf: only one can be set + if self.staked_account_id is not None: + proto_body.staked_account_id.CopyFrom(self.staked_account_id._to_proto()) + elif self.staked_node_id is not None: + proto_body.staked_node_id = self.staked_node_id + + return proto_body + def build_transaction_body(self): """ Builds the transaction body for this account update transaction. @@ -227,3 +376,4 @@ def _get_method(self, channel: _Channel) -> _Method: _Method: An object containing the transaction function to update an account. """ return _Method(transaction_func=channel.crypto.updateAccount, query_func=None) + \ No newline at end of file diff --git a/src/hiero_sdk_python/transaction/transaction.py b/src/hiero_sdk_python/transaction/transaction.py index bf2bedf49..4f629b9f6 100644 --- a/src/hiero_sdk_python/transaction/transaction.py +++ b/src/hiero_sdk_python/transaction/transaction.py @@ -9,7 +9,7 @@ from hiero_sdk_python.crypto.private_key import PrivateKey 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_pb2, transaction_contents_pb2, transaction_pb2) +from hiero_sdk_python.hapi.services import (basic_types_pb2, transaction_pb2, transaction_contents_pb2) from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import SchedulableTransactionBody from hiero_sdk_python.hapi.services.transaction_response_pb2 import (TransactionResponse as TransactionResponseProto) from hiero_sdk_python.hbar import Hbar diff --git a/tests/integration/account_update_transaction_e2e_test.py b/tests/integration/account_update_transaction_e2e_test.py index eba2c5d72..8d33e0389 100644 --- a/tests/integration/account_update_transaction_e2e_test.py +++ b/tests/integration/account_update_transaction_e2e_test.py @@ -112,6 +112,8 @@ def test_integration_account_update_transaction_fails_with_invalid_signature(env account_id = receipt.account_id assert account_id is not None, "Account ID should not be None" + base_info = AccountInfoQuery(account_id).execute(env.client) + # Try to update without signing with the account's key receipt = ( AccountUpdateTransaction() @@ -125,6 +127,11 @@ def test_integration_account_update_transaction_fails_with_invalid_signature(env f"but got {ResponseCode(receipt.status).name}" ) + # Verify nothing changed on-chain + info_after = AccountInfoQuery(account_id).execute(env.client) + assert info_after.account_memo == base_info.account_memo + assert info_after.key.to_bytes_raw() == initial_public_key.to_bytes_raw() + @pytest.mark.integration def test_integration_account_update_transaction_partial_update(env): @@ -187,8 +194,11 @@ def test_integration_account_update_transaction_invalid_auto_renew_period(env): account_id = receipt.account_id assert account_id is not None, "Account ID should not be None" - # Try to update with invalid auto renew period + # Capture existing expiration to ensure it remains unchanged + original_info = AccountInfoQuery(account_id).execute(env.client) invalid_period = Duration(777600000) # 9000 days + + # Try to update with invalid auto renew period receipt = ( AccountUpdateTransaction() .set_account_id(account_id) @@ -196,12 +206,15 @@ def test_integration_account_update_transaction_invalid_auto_renew_period(env): .execute(env.client) ) - # Should fail with AUTORENEW_DURATION_NOT_IN_RANGE assert receipt.status == ResponseCode.AUTORENEW_DURATION_NOT_IN_RANGE, ( f"Account update should have failed with status AUTORENEW_DURATION_NOT_IN_RANGE, " f"but got {ResponseCode(receipt.status).name}" ) + # Ensure expiration time was not modified + info_after = AccountInfoQuery(account_id).execute(env.client) + assert info_after.expiration_time == original_info.expiration_time + def _apply_tiny_max_fee_if_supported(tx, client) -> bool: # Try tx-level setters for attr in ("set_max_transaction_fee", "set_max_fee", "set_transaction_fee"): @@ -250,6 +263,10 @@ def test_account_update_insufficient_fee_with_valid_expiration_bump(env): f"Expected INSUFFICIENT_TX_FEE but got {ResponseCode(receipt.status).name}" ) + # Confirm expiration time did not change + info_after = AccountInfoQuery(account_id).execute(env.client) + assert int(info_after.expiration_time.seconds) == base_expiry_secs + @pytest.mark.integration def test_integration_account_update_transaction_with_only_account_id(env): """Test that AccountUpdateTransaction can execute with only account ID set.""" @@ -272,3 +289,119 @@ def test_integration_account_update_transaction_with_only_account_id(env): assert ( receipt.status == ResponseCode.SUCCESS ), f"Account update failed with status: {ResponseCode(receipt.status).name}" + + # Ensure no fields were unintentionally modified + info = AccountInfoQuery(account_id).execute(env.client) + assert str(info.account_id) == str(account_id) + assert info.key.to_bytes_raw() == env.operator_key.public_key().to_bytes_raw() + + +@pytest.mark.integration +def test_integration_account_update_transaction_with_max_automatic_token_associations(env): + """Test updating max_automatic_token_associations and verifying it persists.""" + # Create initial account + receipt = ( + AccountCreateTransaction() + .set_key(env.operator_key.public_key()) + .set_initial_balance(Hbar(2)) + .execute(env.client) + ) + assert ( + receipt.status == ResponseCode.SUCCESS + ), f"Account creation failed with status: {ResponseCode(receipt.status).name}" + + account_id = receipt.account_id + assert account_id is not None, "Account ID should not be None" + + # Update max_automatic_token_associations + new_max_associations = 100 + receipt = ( + AccountUpdateTransaction() + .set_account_id(account_id) + .set_max_automatic_token_associations(new_max_associations) + .execute(env.client) + ) + assert ( + receipt.status == ResponseCode.SUCCESS + ), f"Account update failed with status: {ResponseCode(receipt.status).name}" + + # Query account info to verify the update persisted + info = AccountInfoQuery(account_id).execute(env.client) + assert ( + info.max_automatic_token_associations == new_max_associations + ), "Max automatic token associations should be updated" + + +@pytest.mark.integration +def test_integration_account_update_transaction_with_staking_fields(env): + """Test updating staking fields (staked_account_id, decline_staking_reward).""" + # Create two accounts - one to stake to + receipt1 = ( + AccountCreateTransaction() + .set_key(env.operator_key.public_key()) + .set_initial_balance(Hbar(2)) + .execute(env.client) + ) + assert receipt1.status == ResponseCode.SUCCESS + staked_account_id = receipt1.account_id + + # Create account to update + receipt2 = ( + AccountCreateTransaction() + .set_key(env.operator_key.public_key()) + .set_initial_balance(Hbar(2)) + .execute(env.client) + ) + assert receipt2.status == ResponseCode.SUCCESS + account_id = receipt2.account_id + + # Update with staking fields + receipt = ( + AccountUpdateTransaction() + .set_account_id(account_id) + .set_staked_account_id(staked_account_id) + .set_decline_staking_reward(True) + .execute(env.client) + ) + assert ( + receipt.status == ResponseCode.SUCCESS + ), f"Account update with staking fields failed with status: {ResponseCode(receipt.status).name}" + + # Verify staking info reflects the updated values + info = AccountInfoQuery(account_id).execute(env.client) + assert info.staked_account_id == staked_account_id, "Staked account ID should match" + assert info.staked_node_id is None, "Staked node ID should be cleared when staking to an account" + assert info.decline_staking_reward is True, "Decline staking reward should be true" + + +@pytest.mark.integration +def test_integration_account_update_transaction_with_staked_node_id(env): + """Test updating with staked_node_id.""" + # Create account to update + receipt = ( + AccountCreateTransaction() + .set_key(env.operator_key.public_key()) + .set_initial_balance(Hbar(2)) + .execute(env.client) + ) + assert receipt.status == ResponseCode.SUCCESS + account_id = receipt.account_id + + # Update with staked_node_id (using node 0 as a test value) + # Note: In a real scenario, you'd use a valid node ID + receipt = ( + AccountUpdateTransaction() + .set_account_id(account_id) + .set_staked_node_id(0) + .execute(env.client) + ) + # This might succeed or fail depending on network state, but should not crash + assert receipt.status in [ + ResponseCode.SUCCESS, + ResponseCode.INVALID_STAKING_ID, + ], f"Unexpected status: {ResponseCode(receipt.status).name}" + + if receipt.status == ResponseCode.SUCCESS: + info = AccountInfoQuery(account_id).execute(env.client) + assert info.staked_node_id == 0 + assert info.staked_account_id is None \ No newline at end of file diff --git a/tests/unit/test_account_update_transaction.py b/tests/unit/test_account_update_transaction.py index 0efdc8741..fac3526f2 100644 --- a/tests/unit/test_account_update_transaction.py +++ b/tests/unit/test_account_update_transaction.py @@ -7,7 +7,7 @@ import pytest # pylint: disable=no-name-in-module -from google.protobuf.wrappers_pb2 import BoolValue, StringValue +from google.protobuf.wrappers_pb2 import BoolValue, Int32Value, StringValue from hiero_sdk_python import Duration, Timestamp from hiero_sdk_python.account.account_id import AccountId @@ -50,6 +50,9 @@ def test_constructor_with_account_params(): receiver_sig_required = True expiration_time = TEST_EXPIRATION_TIME auto_renew_period = TEST_AUTO_RENEW_PERIOD + max_associations = 50 + staked_account_id = AccountId(0, 0, 999) + decline_reward = False params = AccountUpdateParams( account_id=account_id, @@ -58,6 +61,9 @@ def test_constructor_with_account_params(): account_memo=account_memo, receiver_signature_required=receiver_sig_required, expiration_time=expiration_time, + max_automatic_token_associations=max_associations, + staked_account_id=staked_account_id, + decline_staking_reward=decline_reward, ) account_tx = AccountUpdateTransaction(account_params=params) @@ -68,6 +74,10 @@ def test_constructor_with_account_params(): assert account_tx.account_memo == account_memo assert account_tx.receiver_signature_required == receiver_sig_required assert account_tx.expiration_time == expiration_time + assert account_tx.max_automatic_token_associations == max_associations + assert account_tx.staked_account_id == staked_account_id + assert account_tx.staked_node_id is None # Should be cleared when staked_account_id is set + assert account_tx.decline_staking_reward == decline_reward def test_constructor_without_parameters(): @@ -80,6 +90,10 @@ def test_constructor_without_parameters(): assert account_tx.account_memo is None assert account_tx.receiver_signature_required is None assert account_tx.expiration_time is None + assert account_tx.max_automatic_token_associations is None + assert account_tx.staked_account_id is None + assert account_tx.staked_node_id is None + assert account_tx.decline_staking_reward is None def test_account_update_params_default_values(): @@ -92,6 +106,10 @@ def test_account_update_params_default_values(): assert params.account_memo is None assert params.receiver_signature_required is None assert params.expiration_time is None + assert params.max_automatic_token_associations is None + assert params.staked_account_id is None + assert params.staked_node_id is None + assert params.decline_staking_reward is None def test_set_methods(): @@ -117,6 +135,8 @@ def test_set_methods(): "receiver_signature_required", ), ("set_expiration_time", expiration_time, "expiration_time"), + ("set_max_automatic_token_associations", 100, "max_automatic_token_associations"), + ("set_decline_staking_reward", True, "decline_staking_reward"), ] for method_name, value, attr_name in test_cases: @@ -159,6 +179,10 @@ def test_set_methods_require_not_frozen(mock_client): ("set_account_memo", "new memo"), ("set_receiver_signature_required", True), ("set_expiration_time", TEST_EXPIRATION_TIME), + ("set_max_automatic_token_associations", 100), + ("set_staked_account_id", AccountId(0, 0, 888)), + ("set_staked_node_id", 5), + ("set_decline_staking_reward", True), ] for method_name, value in test_cases: @@ -167,6 +191,17 @@ def test_set_methods_require_not_frozen(mock_client): ): getattr(account_tx, method_name)(value) + zero_arg_methods = [ + "clear_staked_account_id", + "clear_staked_node_id", + ] + + for method_name in zero_arg_methods: + with pytest.raises( + Exception, match="Transaction is immutable; it has been frozen" + ): + getattr(account_tx, method_name)() + def test_build_transaction_body(mock_account_ids): """Test building an account update transaction body with valid values.""" @@ -442,6 +477,10 @@ def test_constructor_with_partial_account_params(): assert account_tx.auto_renew_period == AUTO_RENEW_PERIOD assert account_tx.receiver_signature_required is None assert account_tx.expiration_time is None + assert account_tx.max_automatic_token_associations is None + assert account_tx.staked_account_id is None + assert account_tx.staked_node_id is None + assert account_tx.decline_staking_reward is None def test_build_transaction_body_with_none_auto_renew_period(mock_account_ids): @@ -515,3 +554,265 @@ def test_build_scheduled_body(mock_account_ids): schedulable_body.cryptoUpdateAccount.expirationTime == expiration_time._to_protobuf() ) + + +def test_constructor_with_new_fields(): + """Test creating an account update transaction with new fields in params.""" + account_id = AccountId(0, 0, 123) + staked_account_id = AccountId(0, 0, 456) + max_associations = 100 + + params = AccountUpdateParams( + account_id=account_id, + max_automatic_token_associations=max_associations, + staked_account_id=staked_account_id, + decline_staking_reward=True, + ) + + account_tx = AccountUpdateTransaction(account_params=params) + + assert account_tx.account_id == account_id + assert account_tx.max_automatic_token_associations == max_associations + assert account_tx.staked_account_id == staked_account_id + assert account_tx.staked_node_id is None # Should be cleared when staked_account_id is set + assert account_tx.decline_staking_reward is True + + +def test_set_max_automatic_token_associations(): + """Test setting max automatic token associations.""" + account_tx = AccountUpdateTransaction() + max_associations = 100 + result = account_tx.set_max_automatic_token_associations(max_associations) + + assert account_tx.max_automatic_token_associations == max_associations + assert result is account_tx # Method chaining + + +def test_set_max_automatic_token_associations_validation(): + """Test validation for max_automatic_token_associations.""" + account_tx = AccountUpdateTransaction() + + # Test good value: -1 for unlimited + account_tx.set_max_automatic_token_associations(-1) + assert account_tx.max_automatic_token_associations == -1 + + # Test good value: 0 for default + account_tx.set_max_automatic_token_associations(0) + assert account_tx.max_automatic_token_associations == 0 + + # Test good value: 100 + account_tx.set_max_automatic_token_associations(100) + assert account_tx.max_automatic_token_associations == 100 + + # Test None (should be allowed) + account_tx.set_max_automatic_token_associations(None) + assert account_tx.max_automatic_token_associations is None + + # Test bad value: -2 + with pytest.raises(ValueError) as e: + account_tx.set_max_automatic_token_associations(-2) + + assert "must be -1 (unlimited) or a non-negative integer" in str(e.value) + + +def test_set_staked_account_id(): + """Test setting staked account ID.""" + account_tx = AccountUpdateTransaction() + staked_account_id = AccountId(0, 0, 789) + result = account_tx.set_staked_account_id(staked_account_id) + + assert account_tx.staked_account_id == staked_account_id + assert account_tx.staked_node_id is None # Should clear the other field + assert result is account_tx # Method chaining + + # Passing None should clear and set sentinel 0.0.0 + account_tx.set_staked_account_id(None) + assert account_tx.staked_account_id == AccountId(0, 0, 0) + assert account_tx.staked_node_id is None + + +def test_set_staked_node_id(): + """Test setting staked node ID.""" + account_tx = AccountUpdateTransaction() + staked_node_id = 5 + result = account_tx.set_staked_node_id(staked_node_id) + + assert account_tx.staked_node_id == staked_node_id + assert account_tx.staked_account_id is None # Should clear the other field + assert result is account_tx # Method chaining + + # Passing None should clear and set sentinel -1 + account_tx.set_staked_node_id(None) + assert account_tx.staked_node_id == -1 + assert account_tx.staked_account_id is None + + +def test_staked_id_oneof_behavior(): + """Test that staked_account_id and staked_node_id are mutually exclusive.""" + account_tx = AccountUpdateTransaction() + staked_account_id = AccountId(0, 0, 789) + staked_node_id = 5 + + # Set staked_account_id first + account_tx.set_staked_account_id(staked_account_id) + assert account_tx.staked_account_id == staked_account_id + assert account_tx.staked_node_id is None + + # Setting staked_node_id should clear staked_account_id + account_tx.set_staked_node_id(staked_node_id) + assert account_tx.staked_node_id == staked_node_id + assert account_tx.staked_account_id is None + + # Setting staked_account_id again should clear staked_node_id + account_tx.set_staked_account_id(staked_account_id) + assert account_tx.staked_account_id == staked_account_id + assert account_tx.staked_node_id is None + + # Clearing should set sentinel values + account_tx.clear_staked_account_id() + assert account_tx.staked_account_id == AccountId(0, 0, 0) + assert account_tx.staked_node_id is None + + account_tx.clear_staked_node_id() + assert account_tx.staked_node_id == -1 + assert account_tx.staked_account_id is None + + +def test_set_decline_staking_reward(): + """Test setting decline staking reward.""" + account_tx = AccountUpdateTransaction() + + # Test with True + result = account_tx.set_decline_staking_reward(True) + assert account_tx.decline_staking_reward is True + assert result is account_tx + + # Test with False + account_tx.set_decline_staking_reward(False) + assert account_tx.decline_staking_reward is False + + # Test with None + account_tx.set_decline_staking_reward(None) + assert account_tx.decline_staking_reward is None + + +def test_clear_staked_account_id(): + """Test clearing the staked account id using sentinel value.""" + account_tx = AccountUpdateTransaction() + account_tx.set_staked_node_id(5) + account_tx.clear_staked_account_id() + + assert account_tx.staked_account_id == AccountId(0, 0, 0) + assert account_tx.staked_node_id is None + + +def test_clear_staked_node_id(): + """Test clearing the staked node id using sentinel value (-1).""" + account_tx = AccountUpdateTransaction() + account_tx.set_staked_account_id(AccountId(0, 0, 123)) + account_tx.clear_staked_node_id() + + assert account_tx.staked_node_id == -1 + assert account_tx.staked_account_id is None + + +def test_build_transaction_body_with_new_fields(mock_account_ids): + """Test building transaction body with new fields.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + account_id = AccountId(0, 0, 123) + staked_account_id = AccountId(0, 0, 456) + max_associations = 100 + + account_tx = AccountUpdateTransaction() + account_tx.set_account_id(account_id) + account_tx.set_max_automatic_token_associations(max_associations) + account_tx.set_staked_account_id(staked_account_id) + account_tx.set_decline_staking_reward(True) + + account_tx.operator_account_id = operator_id + account_tx.node_account_id = node_account_id + + transaction_body = account_tx.build_transaction_body() + + assert ( + transaction_body.cryptoUpdateAccount.accountIDToUpdate == account_id._to_proto() + ) + assert transaction_body.cryptoUpdateAccount.max_automatic_token_associations == Int32Value( + value=max_associations + ) + assert ( + transaction_body.cryptoUpdateAccount.staked_account_id.accountNum + == staked_account_id.num + ) + assert transaction_body.cryptoUpdateAccount.decline_reward == BoolValue(value=True) + + +def test_build_transaction_body_with_staked_node_id(mock_account_ids): + """Test building transaction body with staked_node_id.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + account_id = AccountId(0, 0, 123) + staked_node_id = 5 + + account_tx = AccountUpdateTransaction() + account_tx.set_account_id(account_id) + account_tx.set_staked_node_id(staked_node_id) + + account_tx.operator_account_id = operator_id + account_tx.node_account_id = node_account_id + + transaction_body = account_tx.build_transaction_body() + + assert ( + transaction_body.cryptoUpdateAccount.accountIDToUpdate == account_id._to_proto() + ) + assert transaction_body.cryptoUpdateAccount.staked_node_id == staked_node_id + # staked_account_id should not be set + assert not transaction_body.cryptoUpdateAccount.HasField("staked_account_id") + + +def test_build_transaction_body_with_optional_new_fields_none(mock_account_ids): + """Test building transaction body when new optional fields are None.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + account_id = AccountId(0, 0, 456) + + account_tx = AccountUpdateTransaction() + account_tx.set_account_id(account_id) + + account_tx.operator_account_id = operator_id + account_tx.node_account_id = node_account_id + + transaction_body = account_tx.build_transaction_body() + + # When new fields are None, they should not be set in the protobuf + assert not transaction_body.cryptoUpdateAccount.HasField( + "max_automatic_token_associations" + ) + assert not transaction_body.cryptoUpdateAccount.HasField("staked_account_id") + assert not transaction_body.cryptoUpdateAccount.HasField("staked_node_id") + assert not transaction_body.cryptoUpdateAccount.HasField("decline_reward") + + +def test_build_transaction_body_with_cleared_staking(mock_account_ids): + """Sentinel values should be emitted when staking is cleared.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + account_id = AccountId(0, 0, 123) + + # Clear staked account + account_tx = AccountUpdateTransaction().set_account_id(account_id) + account_tx.set_staked_account_id(None) + account_tx.operator_account_id = operator_id + account_tx.node_account_id = node_account_id + txn_body = account_tx.build_transaction_body().cryptoUpdateAccount + assert txn_body.staked_account_id.accountNum == 0 + assert txn_body.staked_account_id.realmNum == 0 + assert txn_body.staked_account_id.shardNum == 0 + assert not txn_body.HasField("staked_node_id") + + # Clear staked node + account_tx = AccountUpdateTransaction().set_account_id(account_id) + account_tx.set_staked_node_id(None) + account_tx.operator_account_id = operator_id + account_tx.node_account_id = node_account_id + txn_body = account_tx.build_transaction_body().cryptoUpdateAccount + assert txn_body.staked_node_id == -1 + assert not txn_body.HasField("staked_account_id") \ No newline at end of file