feat(tron): Unfreeze and claiming TRX - core logic - Refactored Unfreeze with freeze. - Simplified some definitions and label use. - Supports UnfreezeBalanceV2Contract and WithdrawExpireUnfreezeContract
What changed, and why it matters
This commit adds support for two new TRON transaction types on Trezor hardware wallets: unfreezing staked TRX and withdrawing expired unfrozen balances. It also renames a field in the freeze/unfreeze messages from 'frozen_balance' to 'balance' and removes unused smart-contract function signatures. The changes are a routine feature addition for TRON staking operations and do not, on their own, look like a security fix or vulnerability.
Review as a normal feature commit. Verify that the new UnfreezeBalanceV2Contract and WithdrawExpireUnfreezeContract flows require user confirmation before signing, that the resource/BANDWIDTH default-value handling is correct for serialization, and that the protobuf field rename is consistent across firmware, client library, and tests. No immediate security response is indicated by the diff alone.
Security signals we found
New transaction-type parsing added to hardware signing path
UI confirmation helper reused across freeze/unfreeze/withdraw operations
Field rename in existing message type (frozen_balance -> balance)
Removal of unused smart-contract function signature constants
Translation signature metadata updated
No changelog entry despite user-facing feature
Evidence from the diff
The patch extends Trezor’s TRON signing flow to handle TronUnfreezeBalanceV2Contract and TronWithdrawUnfreeze. It refactors the confirmation UI so freeze and unfreeze share the same layout helper, updates the python trezorlib decoder to parse the new contract types, and adjusts tests/fixtures accordingly. The field rename from frozen_balance to balance in TronFreezeBalanceV2Contract is a breaking protobuf/message change reflected across firmware and client. Several unused TRC-20 function-signature constants (STAKE, UNSTAKE, CLAIM) are removed from sc_constants.py. No explicit security bug is patched; the commit is presented as a feature.
Changed components
core/src/apps/tron/layout.pycore/src/apps/tron/sc_constants.pycore/src/apps/tron/sign_tx.pypython/src/trezorlib/tron.pytests/device_tests/tron/test_sign_tx.pytests/ui_tests/fixtures.jsoncore/translations/signatures.jsonInspect captured patch +95 / −35
diff --git a/core/src/apps/tron/layout.py b/core/src/apps/tron/layout.py
index ce01d5ef..823fe4eb 100644
--- a/core/src/apps/tron/layout.py
+++ b/core/src/apps/tron/layout.py
@@ -7,11 +7,9 @@ from trezor import TR, strings
from .helpers import get_encoded_address
if TYPE_CHECKING:
- from trezor.messages import (
- TronFreezeBalanceV2Contract,
- TronTransferContract,
- TronTriggerSmartContract,
- )
+ from buffer_types import AnyBytes
+
+ from trezor.messages import TronTransferContract, TronTriggerSmartContract
def format_trx_amount(amount: int) -> str:
@@ -114,30 +112,45 @@ async def confirm_known_trc20_smart_contract(
)
-async def confirm_freeze_balance(contract: TronFreezeBalanceV2Contract) -> None:
+async def confirm_freeze_operations(
+ owner_address: AnyBytes,
+ balance: int,
+ resource: int,
+ title: str,
+) -> None:
from trezor.enums import TronResourceCode
from trezor.ui.layouts import confirm_address, confirm_properties
await confirm_address(
- title=TR.words__staking_from,
- address=get_encoded_address(contract.owner_address),
+ title=title,
+ address=get_encoded_address(owner_address),
chunkify=True,
)
await confirm_properties(
- br_name="confirm_tron_freeze",
+ br_name="confirm_tron_freeze_ops",
title=TR.words__title_summary,
props=(
- (TR.words__amount, format_trx_amount(contract.frozen_balance), False),
+ (TR.words__amount, format_trx_amount(balance), False),
(
TR.words__resource,
- (
- "Energy"
- if contract.resource == TronResourceCode.ENERGY
- else "Bandwidth"
- ),
+ ("Energy" if resource == TronResourceCode.ENERGY else "Bandwidth"),
False,
),
),
hold=True,
)
+
+
+async def confirm_withdraw_unfreeze(owner_address: AnyBytes) -> None:
+ from trezor.ui.layouts import confirm_value
+
+ await confirm_value(
+ title=TR.ethereum__staking_claim_address,
+ value=get_encoded_address(owner_address),
+ description="",
+ chunkify=True,
+ hold=True,
+ br_name="tron/claim",
+ cancel=True,
+ )
diff --git a/core/src/apps/tron/sc_constants.py b/core/src/apps/tron/sc_constants.py
index 746187cc..dd8d0205 100644
--- a/core/src/apps/tron/sc_constants.py
+++ b/core/src/apps/tron/sc_constants.py
@@ -5,14 +5,10 @@ from ubinascii import unhexlify
SC_FUNC_SIG_BYTES = const(4)
SC_ARGUMENT_BYTES = const(32)
SC_ARGUMENT_ADDRESS_BYTES = const(20)
-SC_FUNC_APPROVE_REVOKE_AMOUNT = const(0)
assert SC_ARGUMENT_ADDRESS_BYTES <= SC_ARGUMENT_BYTES
-# Known ERC-20 functions
+# Known TRC-20/ERC-20 functions
SC_FUNC_SIG_TRANSFER = unhexlify("a9059cbb")
SC_FUNC_SIG_APPROVE = unhexlify("095ea7b3")
-SC_FUNC_SIG_STAKE = unhexlify("3a29dbae")
-SC_FUNC_SIG_UNSTAKE = unhexlify("76ec871c")
-SC_FUNC_SIG_CLAIM = unhexlify("33986ffa")
diff --git a/core/src/apps/tron/sign_tx.py b/core/src/apps/tron/sign_tx.py
index 5ac1ecee..030c10b8 100644
--- a/core/src/apps/tron/sign_tx.py
+++ b/core/src/apps/tron/sign_tx.py
@@ -87,6 +87,7 @@ async def process_contract(
# Importing individual enums would de-clutter the code a bit.
# But it causes type error in messages.TronRawContract.type.
+ from trezor import TR
from trezor.enums import TronRawContractType
from trezor.ui.layouts import confirm_tron_send
@@ -107,7 +108,13 @@ async def process_contract(
from trezor.enums import TronResourceCode
contract_type = TronRawContractType.FreezeBalanceV2Contract
- await layout.confirm_freeze_balance(contract)
+
+ await layout.confirm_freeze_operations(
+ owner_address=contract.owner_address,
+ balance=contract.balance,
+ resource=contract.resource,
+ title=TR.ethereum__staking_stake,
+ )
# TRON protocol uses proto3, which omits fields with default values from
# serialization. Since BANDWIDTH=0 is the default, we must set resource=None
@@ -115,9 +122,31 @@ async def process_contract(
if contract.resource == TronResourceCode.BANDWIDTH:
contract = messages.TronFreezeBalanceV2Contract(
owner_address=contract.owner_address,
- frozen_balance=contract.frozen_balance,
+ balance=contract.balance,
resource=None,
)
+ elif messages.TronUnfreezeBalanceV2Contract.is_type_of(contract):
+ from trezor.enums import TronResourceCode
+
+ contract_type = TronRawContractType.UnfreezeBalanceV2Contract
+
+ await layout.confirm_freeze_operations(
+ owner_address=contract.owner_address,
+ balance=contract.balance,
+ resource=contract.resource,
+ title=TR.ethereum__staking_unstake,
+ )
+
+ if contract.resource == TronResourceCode.BANDWIDTH:
+ contract = messages.TronUnfreezeBalanceV2Contract(
+ owner_address=contract.owner_address,
+ balance=contract.balance,
+ resource=None,
+ )
+
+ elif messages.TronWithdrawUnfreeze.is_type_of(contract):
+ contract_type = TronRawContractType.WithdrawExpireUnfreezeContract
+ await layout.confirm_withdraw_unfreeze(contract.owner_address)
else:
raise DataError("Tron: contract type unknown")
diff --git a/core/translations/signatures.json b/core/translations/signatures.json
index dbb29d1e..b74082e5 100644
--- a/core/translations/signatures.json
+++ b/core/translations/signatures.json
@@ -1,8 +1,8 @@
{
"current": {
- "merkle_root": "c02c7d242c60413f8581831d8b5e1f8d6526fea0eda9890c37ecc0a2c6ae54e4",
- "datetime": "2026-02-19T11:54:02.806316+00:00",
- "commit": "cf7cb7aa4cbfd271485ab18f9c504ef432fc8e86"
+ "merkle_root": "7eadd377c65d94120c0c7b0fb5b72176b68855f62520a3258a7e2a997d4c6ba8",
+ "datetime": "2026-02-19T15:57:11.398264+00:00",
+ "commit": "36b8bbf6c2d783a65d2843396770cd1d39869c80"
},
"history": [
{
diff --git a/python/src/trezorlib/tron.py b/python/src/trezorlib/tron.py
index 4f9c5e61..256fc5f9 100644
--- a/python/src/trezorlib/tron.py
+++ b/python/src/trezorlib/tron.py
@@ -12,6 +12,8 @@ if TYPE_CHECKING:
messages.TronTransferContract,
messages.TronTriggerSmartContract,
messages.TronFreezeBalanceV2Contract,
+ messages.TronUnfreezeBalanceV2Contract,
+ messages.TronWithdrawUnfreeze,
]
DEFAULT_BIP32_PATH = "m/44h/195h/0h/0/0"
@@ -34,9 +36,11 @@ def from_raw_data(
raise ValueError("Only single contract transactions are supported.")
contract_type = raw_tx.contract[0].type
+ parameter_value = raw_tx.contract[0].parameter.value
+
if contract_type == messages.TronRawContractType.TransferContract:
raw_contract = load_message(
- io.BytesIO(raw_tx.contract[0].parameter.value),
+ io.BytesIO(parameter_value),
messages.TronTransferContract,
)
contract = messages.TronTransferContract(
@@ -46,7 +50,7 @@ def from_raw_data(
)
elif contract_type == messages.TronRawContractType.TriggerSmartContract:
raw_contract = load_message(
- io.BytesIO(raw_tx.contract[0].parameter.value),
+ io.BytesIO(parameter_value),
messages.TronTriggerSmartContract,
)
contract = messages.TronTriggerSmartContract(
@@ -56,14 +60,32 @@ def from_raw_data(
)
elif contract_type == messages.TronRawContractType.FreezeBalanceV2Contract:
raw_contract = load_message(
- io.BytesIO(raw_tx.contract[0].parameter.value),
+ io.BytesIO(parameter_value),
messages.TronFreezeBalanceV2Contract,
)
contract = messages.TronFreezeBalanceV2Contract(
owner_address=raw_contract.owner_address,
- frozen_balance=raw_contract.frozen_balance,
+ balance=raw_contract.balance,
+ resource=raw_contract.resource,
+ )
+ elif contract_type == messages.TronRawContractType.UnfreezeBalanceV2Contract:
+ raw_contract = load_message(
+ io.BytesIO(parameter_value),
+ messages.TronUnfreezeBalanceV2Contract,
+ )
+ contract = messages.TronUnfreezeBalanceV2Contract(
+ owner_address=raw_contract.owner_address,
+ balance=raw_contract.balance,
resource=raw_contract.resource,
)
+ elif contract_type == messages.TronRawContractType.WithdrawExpireUnfreezeContract:
+ raw_contract = load_message(
+ io.BytesIO(parameter_value),
+ messages.TronWithdrawUnfreeze,
+ )
+ contract = messages.TronWithdrawUnfreeze(
+ owner_address=raw_contract.owner_address,
+ )
else:
raise ValueError(f"Unsupported contract type: {contract_type}")
diff --git a/tests/device_tests/tron/test_sign_tx.py b/tests/device_tests/tron/test_sign_tx.py
index 28f61906..1b74d64f 100644
--- a/tests/device_tests/tron/test_sign_tx.py
+++ b/tests/device_tests/tron/test_sign_tx.py
@@ -108,7 +108,7 @@ def test_ui_cancel_unknown_contract(session: Session, fixture: str):
def make_contract(contract):
type_name = contract["_message_type"]
- assert type_name.startswith("Tron") and type_name.endswith("Contract")
+ assert type_name.startswith("Tron")
cls = getattr(messages, type_name)
return protobuf.dict_to_proto(cls, contract)
diff --git a/tests/ui_tests/fixtures.json b/tests/ui_tests/fixtures.json
index 958e124e..3bf5fe50 100644
--- a/tests/ui_tests/fixtures.json
+++ b/tests/ui_tests/fixtures.json
@@ -14125,15 +14125,15 @@
"T3B1_de_tron-test_get_address.py::test_get_address_chunkify_details[parameters3-result3]": "ab37ec9042efdabb6b6af771790fd6652fa5d918ca0d372e1c86d184957b2749",
"T3B1_de_tron-test_get_address.py::test_get_address_chunkify_details[parameters4-result4]": "2032a5ce75b2db57de100fd38db124180aa00a22b5c80a7ecf8057bfd486df2a",
"T3B1_de_tron-test_get_address.py::test_invalid_path": "c92ee4e050daadcfc7cc95657b00deed76bb463d486eefcc66455bdbe60faa33",
-"T3B1_de_tron-test_sign_tx.py::test_cancel_sign_tx[Note_hello_world]": "d9814f5442a86fc8e221bce338d0c31291831fdf2a02fc83b969f94c2de488a1",
+"T3B1_de_tron-test_sign_tx.py::test_cancel_sign_tx[Note_hello_world]": "0baaf88cb1589c53c04afe57ae707814adbe1be140b80e46571007bbe6674bf2",
"T3B1_de_tron-test_sign_tx.py::test_cancel_sign_tx[Stake_for_Energy]": "b1965c0e24627eb884d9e75b155ee8d745d6dd036ac0ff7ebc87748549833304",
"T3B1_de_tron-test_sign_tx.py::test_cancel_sign_tx[TransferContract]": "2bca0c483330a8db2e0f2760ffac7dd4284a5d846e97ba8a7e5e2751b0c3fcee",
"T3B1_de_tron-test_sign_tx.py::test_cancel_sign_tx[TriggerSmartContract_USDT_transfer]": "4c319ff439df753b5cebbdb7e4bd18d07a0f47bb2de70542c642a21a1d27a559",
"T3B1_de_tron-test_sign_tx.py::test_sign_tx[Approve_TRC-20]": "680b6acdbe3d42df71bb3a4710e236abf1d5998535b29497f7763b87634ba965",
"T3B1_de_tron-test_sign_tx.py::test_sign_tx[Approve_TRC-20_Unlimited]": "4bd8a18394e2328c3b2176ce4f991d103386fcc81d3d960b02b975aeb79936af",
-"T3B1_de_tron-test_sign_tx.py::test_sign_tx[Claim_Unfrozen_Balance]": "c04bc2a8e85c4c20cca056efdd32653805d2914994df4fd1fe423cfb50729f9d",
-"T3B1_de_tron-test_sign_tx.py::test_sign_tx[Note_bad_utf8_bytes]": "e0890468a8fa5e74ba84cedd0c473dda57a3d8bac332924ce564812539c9b22a",
-"T3B1_de_tron-test_sign_tx.py::test_sign_tx[Note_hello_world]": "27cc4902f9cabc93a2937768d0e294084238a0963f8ac0d83bd77135bbe0d39a",
+"T3B1_de_tron-test_sign_tx.py::test_sign_tx[Claim_Unfrozen_Balance]": "7f9435fae1d42871d35a99e0382b2d9c744b6bf175e17c8e93b24645c231a467",
+"T3B1_de_tron-test_sign_tx.py::test_sign_tx[Note_bad_utf8_bytes]": "49f12a827ad9494e7050f3952207eb13a6774733d4fdf833b56da381d91e5ba7",
+"T3B1_de_tron-test_sign_tx.py::test_sign_tx[Note_hello_world]": "c4124548d2d72feb26941bcafec575e560cf92aabcefa329b3e9a61af68fa4fc",
"T3B1_de_tron-test_sign_tx.py::test_sign_tx[Note_too_long_string]": "c92ee4e050daadcfc7cc95657b00deed76bb463d486eefcc66455bdbe60faa33",
"T3B1_de_tron-test_sign_tx.py::test_sign_tx[Revoke_TRC-20]": "eddaad4e039bd366c917c2ffecce088f6239c8105ae53002abbd9ff6f0478042",
"T3B1_de_tron-test_sign_tx.py::test_sign_tx[Stake_for_Bandwidth]": "d045ec23e87f3202c782b75157a39aac009abae2010848382930a9d4a942ac08",
@@ -14142,7 +14142,7 @@
"T3B1_de_tron-test_sign_tx.py::test_sign_tx[TransferContract_amount_int64_max]": "e6f3a8774653bdfd89389f78d52a4451970dba00c2553165b6d49832310ac54b",
"T3B1_de_tron-test_sign_tx.py::test_sign_tx[TriggerSmartContract_USDT_transfer]": "e38caf95141c76ec3fb1342d5043c8be8cfafad8d20d3a69a230139b0234009c",
"T3B1_de_tron-test_sign_tx.py::test_sign_tx[TriggerSmartContract_transfer_TRC20_fee_too_high]": "c92ee4e050daadcfc7cc95657b00deed76bb463d486eefcc66455bdbe60faa33",
-"T3B1_de_tron-test_sign_tx.py::test_sign_tx[TriggerSmartContract_unknown_contract]": "9b7b04213b6942274ecbd2b643f5bf12e515bdda6d02d1ebbdec6ed53d618168",
+"T3B1_de_tron-test_sign_tx.py::test_sign_tx[TriggerSmartContract_unknown_contract]": "2a8f2f16a85a1c8b2abf4080183a933c2b6fc03216c510a02445ef660e3153f2",
"T3B1_de_tron-test_sign_tx.py::test_sign_tx[Unstake_for_Bandwidth]": "782cf8a080e9591d3c490a71705f0bcb3727b399739299b10616517d2f77b43d",
"T3B1_de_tron-test_sign_tx.py::test_sign_tx[Unstake_for_Energy]": "4f62de64fa8d15985ece148f121f0abc634d934df55d82290f9df8f951b5ddae",
"T3B1_de_tron-test_sign_tx.py::test_ui_cancel_flow[Stake_for_Bandwidth]": "11641613af14314f105b736779540ead8845643d0be1e46a40cfd283b239469c",
Why this scored 29/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.