Skip to content

Commit 5490cfb

Browse files
feat: Add AssessedCustomFee model with protobuf support and tests (hiero-ledger#1660)
Signed-off-by: Siddhartha Ganguly <gangulysiddhartha22@gmail.com> Signed-off-by: gangulysiddhartha22-cmyk <gangulysiddhartha22@gmail.com>
1 parent fce2def commit 5490cfb

File tree

5 files changed

+303
-2
lines changed

5 files changed

+303
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
3131
- Format `tests/unit/custom_fee_test.py` with black for code style consistency. (#1525)
3232

3333
### Added
34+
- Added `AssessedCustomFee` domain model to represent assessed custom fees. (`#1637`)
3435
- Add __repr__ method for ContractId class to improve debugging (#1714)
3536
- Added Protobuf Training guide to enhance developer understanding of proto serialization
3637
and deserialization (#1645)

src/hiero_sdk_python/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
from .tokens.token_unpause_transaction import TokenUnpauseTransaction
5252
from .tokens.token_pause_transaction import TokenPauseTransaction
5353
from .tokens.token_airdrop_claim import TokenClaimAirdropTransaction
54+
from .tokens.assessed_custom_fee import AssessedCustomFee
5455

5556
# Transaction
5657
from .transaction.transaction import Transaction
@@ -202,6 +203,7 @@
202203
"HbarTransfer",
203204
"TokenPauseTransaction",
204205
"TokenUnpauseTransaction",
206+
"AssessedCustomFee",
205207

206208
# Transaction
207209
"Transaction",
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from dataclasses import dataclass, field
2+
from typing import Optional
3+
4+
from hiero_sdk_python.account.account_id import AccountId
5+
from hiero_sdk_python.hapi.services.custom_fees_pb2 import (
6+
AssessedCustomFee as AssessedCustomFeeProto,
7+
)
8+
from hiero_sdk_python.tokens.token_id import TokenId
9+
10+
11+
@dataclass
12+
class AssessedCustomFee:
13+
"""Assessed custom fee information included in transaction records.
14+
15+
This class represents fees assessed due to custom fee schedules on tokens or
16+
topics. It appears in `TransactionRecord.assessed_custom_fees` (repeated field).
17+
18+
Example:
19+
Suppose you have a TransactionRecord from getTransactionRecord():
20+
21+
record = client.get_transaction_record(tx_id)
22+
23+
for fee in record.assessed_custom_fees:
24+
if fee.token_id is None:
25+
print(f"HBAR fee of {fee.amount} tinybars collected by {fee.fee_collector_account_id}")
26+
else:
27+
print(f"Token fee of {fee.amount} units of {fee.token_id} "
28+
f"collected by {fee.fee_collector_account_id}, "
29+
f"paid by {', '.join(str(p) for p in fee.effective_payer_account_ids)}")
30+
"""
31+
32+
amount: int
33+
"""The amount of the fee assessed, in the smallest units of the token (or tinybars for HBAR)."""
34+
35+
token_id: Optional[TokenId] = None
36+
"""The ID of the token used to pay the fee; None if paid in HBAR."""
37+
38+
fee_collector_account_id: Optional[AccountId] = None
39+
"""The account ID that collects/receives this assessed custom fee (required field)."""
40+
41+
effective_payer_account_ids: list[AccountId] = field(default_factory=list)
42+
"""The list of accounts that effectively paid this assessed fee (repeated field)."""
43+
44+
def __post_init__(self) -> None:
45+
if self.fee_collector_account_id is None:
46+
raise ValueError(
47+
"fee_collector_account_id is required for AssessedCustomFee"
48+
)
49+
50+
@classmethod
51+
def _from_proto(cls, proto: AssessedCustomFeeProto) -> "AssessedCustomFee":
52+
"""Create an AssessedCustomFee instance from the protobuf message."""
53+
token_id = (
54+
TokenId._from_proto(proto.token_id) if proto.HasField("token_id") else None
55+
)
56+
57+
if not proto.HasField("fee_collector_account_id"):
58+
raise ValueError(
59+
"fee_collector_account_id is required in AssessedCustomFee proto"
60+
)
61+
62+
return cls(
63+
amount=proto.amount,
64+
token_id=token_id,
65+
fee_collector_account_id=AccountId._from_proto(
66+
proto.fee_collector_account_id
67+
),
68+
effective_payer_account_ids=[
69+
AccountId._from_proto(payer_proto)
70+
for payer_proto in proto.effective_payer_account_id
71+
],
72+
)
73+
74+
def _to_proto(self) -> AssessedCustomFeeProto:
75+
"""Convert this AssessedCustomFee instance back to a protobuf message."""
76+
proto = AssessedCustomFeeProto(
77+
amount=self.amount,
78+
fee_collector_account_id=self.fee_collector_account_id._to_proto(),
79+
)
80+
81+
if self.token_id is not None:
82+
proto.token_id.CopyFrom(self.token_id._to_proto())
83+
84+
for payer in self.effective_payer_account_ids:
85+
proto.effective_payer_account_id.append(payer._to_proto())
86+
87+
return proto
88+
89+
def __str__(self) -> str:
90+
"""Returns a human-readable string representation."""
91+
return (
92+
f"AssessedCustomFee("
93+
f"amount={self.amount}, "
94+
f"token_id={self.token_id}, "
95+
f"fee_collector_account_id={self.fee_collector_account_id}, "
96+
f"effective_payer_account_ids={self.effective_payer_account_ids}"
97+
f")"
98+
)

tests/integration/account_id_population_e2e_test.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def test_populate_account_id_num(env, evm_address):
4949
), "Expected child transaction for auto-account creation"
5050

5151
created_account_id = transfer_receipt.children[0].account_id
52-
assert created_account_id is not None
52+
assert created_account_id is not None, f"AccountId not found in child transaction: {transfer_receipt.children[0]}"
5353

5454
mirror_account_id = AccountId.from_evm_address(evm_address, 0, 0)
5555
assert mirror_account_id.num == 0
@@ -92,7 +92,8 @@ def test_populate_account_id_evm_address(env, evm_address):
9292
), "Expected child transaction for auto-account creation"
9393

9494
created_account_id = transfer_receipt.children[0].account_id
95-
assert created_account_id is not None
95+
assert created_account_id is not None, f"AccountId not found in child transaction: {transfer_receipt.children[0]}"
96+
9697
assert created_account_id.evm_address is None
9798

9899
# Wait for mirror_node to update
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import pytest
2+
3+
from hiero_sdk_python.account.account_id import AccountId
4+
from hiero_sdk_python.hapi.services.custom_fees_pb2 import AssessedCustomFee as AssessedCustomFeeProto
5+
from hiero_sdk_python.tokens.assessed_custom_fee import AssessedCustomFee
6+
from hiero_sdk_python.tokens.token_id import TokenId
7+
8+
9+
pytestmark = pytest.mark.unit
10+
11+
12+
# If conftest.py has fixtures like sample_account_id or sample_token_id, use them.
13+
# Otherwise, define simple ones here (adjust shard/realm/num as needed for realism).
14+
@pytest.fixture
15+
def sample_account_id() -> AccountId:
16+
return AccountId(shard=0, realm=0, num=123456)
17+
18+
19+
@pytest.fixture
20+
def sample_token_id() -> TokenId:
21+
return TokenId(shard=0, realm=0, num=789012)
22+
23+
24+
@pytest.fixture
25+
def another_account_id() -> AccountId:
26+
return AccountId(shard=0, realm=0, num=999999)
27+
28+
29+
def test_constructor_all_fields(
30+
sample_account_id: AccountId,
31+
sample_token_id: TokenId,
32+
another_account_id: AccountId,
33+
):
34+
payers = [sample_account_id, another_account_id]
35+
fee = AssessedCustomFee(
36+
amount=1_500_000_000,
37+
token_id=sample_token_id,
38+
fee_collector_account_id=sample_account_id,
39+
effective_payer_account_ids=payers,
40+
)
41+
assert fee.amount == 1_500_000_000
42+
assert fee.token_id == sample_token_id
43+
assert fee.fee_collector_account_id == sample_account_id
44+
assert fee.effective_payer_account_ids == payers
45+
# Protect against breaking changes
46+
assert hasattr(fee, 'amount')
47+
assert hasattr(fee, 'token_id')
48+
assert hasattr(fee, 'fee_collector_account_id')
49+
assert hasattr(fee, 'effective_payer_account_ids')
50+
51+
52+
def test_constructor_hbar_case(sample_account_id: AccountId):
53+
fee = AssessedCustomFee(
54+
amount=100_000_000,
55+
token_id=None,
56+
fee_collector_account_id=sample_account_id,
57+
)
58+
assert fee.amount == 100_000_000
59+
assert fee.token_id is None
60+
assert fee.fee_collector_account_id == sample_account_id
61+
assert fee.effective_payer_account_ids == []
62+
63+
64+
def test_constructor_empty_payers(sample_account_id: AccountId, sample_token_id: TokenId):
65+
fee = AssessedCustomFee(
66+
amount=420,
67+
token_id=sample_token_id,
68+
fee_collector_account_id=sample_account_id,
69+
effective_payer_account_ids=[],
70+
)
71+
assert fee.effective_payer_account_ids == []
72+
assert fee.token_id == sample_token_id
73+
74+
def test_constructor_missing_fee_collector_raises():
75+
"""Verify that omitting fee_collector_account_id raises ValueError."""
76+
with pytest.raises(ValueError, match="fee_collector_account_id is required"):
77+
AssessedCustomFee(
78+
amount=100,
79+
token_id=None,
80+
fee_collector_account_id=None,
81+
)
82+
83+
def test_from_proto_missing_token_id(sample_account_id: AccountId):
84+
"""Verify that absence of token_id in protobuf correctly maps to None."""
85+
proto = AssessedCustomFeeProto(
86+
amount=750_000,
87+
fee_collector_account_id=sample_account_id._to_proto(),
88+
# intentionally no token_id → proto.HasField("token_id") should be False
89+
)
90+
91+
fee = AssessedCustomFee._from_proto(proto)
92+
93+
assert fee.amount == 750_000
94+
assert fee.token_id is None, "token_id should be None when not present in proto"
95+
assert fee.fee_collector_account_id == sample_account_id
96+
assert fee.effective_payer_account_ids == [], "effective payers should default to empty list"
97+
98+
def test_from_proto_with_token_id(sample_account_id: AccountId, sample_token_id: TokenId):
99+
"""Verify that token_id is correctly deserialized when present in proto."""
100+
proto = AssessedCustomFeeProto(
101+
amount=500_000,
102+
token_id=sample_token_id._to_proto(),
103+
fee_collector_account_id=sample_account_id._to_proto(),
104+
)
105+
proto.effective_payer_account_id.append(sample_account_id._to_proto())
106+
107+
fee = AssessedCustomFee._from_proto(proto)
108+
109+
assert fee.amount == 500_000
110+
assert fee.token_id is not None
111+
assert fee.token_id == sample_token_id
112+
assert fee.fee_collector_account_id == sample_account_id
113+
assert len(fee.effective_payer_account_ids) == 1
114+
115+
def test_from_proto_missing_fee_collector_raises():
116+
"""Verify that missing fee_collector_account_id in proto raises ValueError."""
117+
proto = AssessedCustomFeeProto(amount=750_000)
118+
with pytest.raises(ValueError, match="fee_collector_account_id is required"):
119+
AssessedCustomFee._from_proto(proto)
120+
121+
def test_to_proto_basic_fields(
122+
sample_account_id: AccountId,
123+
sample_token_id: TokenId,
124+
another_account_id: AccountId,
125+
):
126+
"""Verify that all basic fields are correctly serialized to protobuf."""
127+
payers = [sample_account_id, another_account_id]
128+
129+
fee = AssessedCustomFee(
130+
amount=2_000_000,
131+
token_id=sample_token_id,
132+
fee_collector_account_id=sample_account_id,
133+
effective_payer_account_ids=payers,
134+
)
135+
136+
proto = fee._to_proto()
137+
138+
# Core presence and value checks
139+
assert proto.amount == 2_000_000
140+
assert proto.HasField("token_id"), "token_id should be set when present"
141+
assert proto.HasField("fee_collector_account_id")
142+
assert len(proto.effective_payer_account_id) == 2, "should serialize both effective payers"
143+
144+
# Deeper structural checks (helps catch broken _to_proto implementations)
145+
assert proto.token_id.shardNum == sample_token_id.shard
146+
assert proto.token_id.realmNum == sample_token_id.realm
147+
assert proto.token_id.tokenNum == sample_token_id.num
148+
149+
# Optional: verify collector (often useful when debugging)
150+
assert proto.fee_collector_account_id.shardNum == sample_account_id.shard
151+
assert proto.fee_collector_account_id.realmNum == sample_account_id.realm
152+
assert proto.fee_collector_account_id.accountNum == sample_account_id.num
153+
154+
155+
def test_round_trip_conversion(
156+
sample_account_id: AccountId,
157+
sample_token_id: TokenId,
158+
):
159+
original = AssessedCustomFee(
160+
amount=987_654_321,
161+
token_id=sample_token_id,
162+
fee_collector_account_id=sample_account_id,
163+
effective_payer_account_ids=[sample_account_id],
164+
)
165+
166+
proto = original._to_proto()
167+
reconstructed = AssessedCustomFee._from_proto(proto)
168+
169+
assert reconstructed.amount == original.amount
170+
assert reconstructed.token_id == original.token_id
171+
assert reconstructed.fee_collector_account_id == original.fee_collector_account_id
172+
assert reconstructed.effective_payer_account_ids == original.effective_payer_account_ids
173+
174+
175+
def test_str_contains_expected_fields(
176+
sample_account_id: AccountId,
177+
sample_token_id: TokenId,
178+
):
179+
fee = AssessedCustomFee(
180+
amount=5_000_000,
181+
token_id=sample_token_id,
182+
fee_collector_account_id=sample_account_id,
183+
effective_payer_account_ids=[sample_account_id],
184+
)
185+
186+
s = str(fee)
187+
assert "AssessedCustomFee" in s
188+
assert "amount=5000000" in s
189+
assert str(sample_token_id) in s
190+
assert str(sample_account_id) in s
191+
assert "effective_payer_account_ids" in s
192+
193+
# HBAR case
194+
hbar_fee = AssessedCustomFee(
195+
amount=123_456,
196+
fee_collector_account_id=sample_account_id,
197+
)
198+
hbar_str = str(hbar_fee)
199+
assert "token_id=None" in hbar_str

0 commit comments

Comments
 (0)