diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 2e97f59..9b9892e 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -56,7 +56,7 @@ jobs:
- run: uv build
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@v7
with:
name: dist
path: dist/*
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5423c88..8317c4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,39 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
---
+## [2026.2.2] — 2026-05-11
+
+### Added
+
+- `payment_method` option (`"ton"` / `"usdt_ton"`) for:
+ - `purchase_stars()`
+ - `purchase_premium()`
+ - `giveaway_stars()`
+ - `giveaway_premium()`
+
+### Changed
+
+- Added runtime validation for `payment_method` via `SUPPORTED_PAYMENT_METHODS` and `ConfigurationError.INVALID_PAYMENT_METHOD`
+- Updated method docstrings to explicitly document recipient/channel formats:
+ - `@username` / `username` / `https://t.me/username`
+- `get_wallet()` now returns balances as separate fields: `ton_balance` and `usdt_balance`
+- Wallet/system test output now prints TON and USDT balances on separate lines
+- Balance checks are now method-aware with explicit thresholds:
+ - `ton`: minimum TON balance threshold via `MIN_TON_BALANCE` (based on current 50 Stars purchase amount)
+ - `usdt_ton`: minimum USDT balance threshold via `MIN_USDT_BALANCE` (based on current 50 Stars purchase amount)
+
+### Tests
+
+- Extended stars and premium test suites to cover:
+ - invalid payment method
+ - payment method propagation to `init*Request` payloads
+ - accepted query formats (`@`, plain username, `t.me` link)
+- Extended wallet tests to verify separate TON/USDT balance values in `WalletInfo`
+
+### Documentation
+
+- Simplified `README` usage example
+
## [2026.2.1] — 2026-05-03
### Fixed
@@ -48,37 +81,44 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
### Added
**Giveaways**
+
- `giveaway_stars(channel, winners, amount)` — Stars giveaway; 1–5 winners, 500–1 000 000 stars each
- `giveaway_premium(channel, winners, months)` — Premium giveaway; 1–24 000 winners, 3/6/12 months each
- `StarsGiveawayResult`, `PremiumGiveawayResult` result types
**Telegram Ads**
+
- `recharge_ads(account, amount)` — top up a Telegram Ads account; 1–1 000 000 000 TON
- `AdsRechargeResult` result type
**Marketplace**
+
- `search_usernames(query?, sort?, filter?, offset_id?)` — search Fragment usernames; `sort`: `price_desc / price_asc / listed / ending`, `filter`: `auction / sale / sold`
- `search_numbers(query?, sort?, filter?, offset_id?)` — search Fragment anonymous numbers; same `sort` / `filter` / pagination semantics
- `search_gifts(query?, collection?, sort?, filter?, view?, attr?, offset?)` — search Fragment gifts; `attr` accepts `{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}`
- `UsernamesResult`, `NumbersResult`, `GiftsResult` result types
**Anonymous numbers**
+
- `get_login_code(number)` — fetch the current pending login code
- `toggle_login_codes(number, can_receive)` — enable or disable login code delivery
- `terminate_sessions(number)` — terminate all active Telegram sessions (two-step flow handled internally)
- `LoginCodeResult`, `TerminateSessionsResult` result types; `AnonymousNumberError` exception
**Raw API**
+
- `FragmentClient.call(method, data, *, page_url)` — raw request to any Fragment API method
- `FRAGMENT_BASE_URL` constant — base URL shared across all page constants and headers
**Examples**
+
- `examples/client/` — `wallet_info.py` (wallet info), `raw_api_call.py` (raw API call)
- `examples/numbers/` — `manage_number.py` (login code fetch, session termination)
- `examples/auctions/` — `search_usernames.py`, `search_numbers.py`, `search_gifts.py` (marketplace search with pagination)
- `examples/purchase/` — `send_stars.py`, `send_premium.py`, `topup_ton_balance.py`, `run_stars_giveaway.py`, `run_premium_giveaway.py`, `recharge_ads_balance.py`
### Changed
+
- All result types now expose a unified `amount` field (`months` and `stars` removed)
- `__repr__` includes the unit — `3 months`, `500 stars`, etc.
- `timestamp` removed from all result dataclasses
@@ -91,15 +131,18 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
## [2026.0.2] — 2026-03-20
### Added
+
- `timeout` parameter on `FragmentClient` (default `30.0` s) — passed through to every HTTP request
### Changed
+
- Cookie validation: narrowed type internally so no `# type: ignore` is needed in `FragmentClient.__init__`
- `WALLET_CLASSES` typed as `dict[str, Any]` so mypy resolves `from_mnemonic` correctly
- All four `examples/` files updated to `async with FragmentClient`, f-strings, and aligned error messages
- README usage section rewritten with a single comprehensive `async with` example
### Fixed
+
- mypy: missing return path in `process_transaction` after retry loop
- mypy: `cookies` union-attr error in `FragmentClient.__init__`
@@ -108,6 +151,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
## [2026.0.1] — 2026-03-16
### Added
+
- Initial stable release of `pyfragment`
- `FragmentClient` — async client for the Fragment.com API with context manager support (`async with`)
- `purchase_premium(username, months)` — purchase Telegram Premium for any user (3, 6, or 12 months)
@@ -119,6 +163,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
- `py.typed` marker — full PEP 561 typing support for type-checkers
- `__repr__` on all result types for readable debug output
+[2026.2.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.2
[2026.2.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.1
[2026.2.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.0
[2026.1.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.1.0
diff --git a/README.md b/README.md
index bb94671..7677515 100644
--- a/README.md
+++ b/README.md
@@ -4,15 +4,14 @@
diff --git a/examples/client/wallet_info.py b/examples/client/wallet_info.py
index 5ac628f..bfc6620 100644
--- a/examples/client/wallet_info.py
+++ b/examples/client/wallet_info.py
@@ -1,5 +1,5 @@
"""
-Example: fetch wallet address, state, and balance.
+Example: fetch wallet address, state, and separate TON/USDT balances.
Cookies can be passed as a dict or as a JSON string.
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
@@ -35,7 +35,8 @@ async def main() -> None:
wallet = await client.get_wallet()
print(f"Address: {wallet.address}")
print(f"State: {wallet.state}")
- print(f"Balance: {wallet.balance} TON")
+ print(f"Balance: {wallet.ton_balance} TON")
+ print(f"Balance: {wallet.usdt_balance} USDT")
if __name__ == "__main__":
diff --git a/examples/purchase/recharge_ads_balance.py b/examples/purchase/recharge_ads_balance.py
index d0084bc..5e4c457 100644
--- a/examples/purchase/recharge_ads_balance.py
+++ b/examples/purchase/recharge_ads_balance.py
@@ -2,7 +2,7 @@
Example: recharge your own Telegram Ads account with TON.
Amount must be an integer between 1 and 1 000 000 000 TON.
-Your wallet must hold at least the recharge amount + ~0.056 TON for gas.
+Your wallet must satisfy the current minimum TON threshold and transaction cost.
"""
import asyncio
diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py
index d0a39b8..9fb8565 100644
--- a/examples/purchase/run_premium_giveaway.py
+++ b/examples/purchase/run_premium_giveaway.py
@@ -3,6 +3,8 @@ Example: run a Telegram Premium giveaway for a channel.
winners must be an integer between 1 and 24 000.
months (Premium duration per winner) must be 3, 6, or 12.
+payment_method can be "ton" or "usdt_ton".
+Channel can be "@channel", "channel", or "https://t.me/channel".
"""
import asyncio
@@ -24,15 +26,21 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
-CHANNEL = "@channel"
+CHANNEL = "https://t.me/channel"
WINNERS = 10 # 1–24 000
MONTHS = 3 # 3, 6 or 12
+PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
try:
- result = await client.giveaway_premium(CHANNEL, winners=WINNERS, months=MONTHS)
+ result = await client.giveaway_premium(
+ CHANNEL,
+ winners=WINNERS,
+ months=MONTHS,
+ payment_method=PAYMENT_METHOD,
+ )
except UserNotFoundError:
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
return
diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py
index 5a73310..7f98ed9 100644
--- a/examples/purchase/run_stars_giveaway.py
+++ b/examples/purchase/run_stars_giveaway.py
@@ -3,6 +3,8 @@ Example: run a Telegram Stars giveaway for a channel.
winners must be an integer between 1 and 5.
amount (stars per winner) must be an integer between 500 and 1 000 000.
+payment_method can be "ton" or "usdt_ton".
+Channel can be "@channel", "channel", or "https://t.me/channel".
"""
import asyncio
@@ -24,15 +26,21 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
-CHANNEL = "@channel"
+CHANNEL = "https://t.me/channel"
WINNERS = 3 # 1–5
AMOUNT = 1000 # 500–1 000 000 stars per winner
+PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
try:
- result = await client.giveaway_stars(CHANNEL, winners=WINNERS, amount=AMOUNT)
+ result = await client.giveaway_stars(
+ CHANNEL,
+ winners=WINNERS,
+ amount=AMOUNT,
+ payment_method=PAYMENT_METHOD,
+ )
except UserNotFoundError:
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
return
diff --git a/examples/purchase/send_premium.py b/examples/purchase/send_premium.py
index f82f1ed..a28cfa7 100644
--- a/examples/purchase/send_premium.py
+++ b/examples/purchase/send_premium.py
@@ -3,6 +3,8 @@ Example: purchase Telegram Premium for a user.
Supported durations: 3, 6, or 12 months.
Set show_sender=False to send anonymously.
+payment_method can be "ton" or "usdt_ton".
+Username can be "@username", "username", or "https://t.me/username".
"""
import asyncio
@@ -24,14 +26,20 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
-USERNAME = "@username"
+USERNAME = "https://t.me/username"
MONTHS = 3 # 3, 6 or 12
+PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
try:
- result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True)
+ result = await client.purchase_premium(
+ USERNAME,
+ months=MONTHS,
+ show_sender=True,
+ payment_method=PAYMENT_METHOD,
+ )
except UserNotFoundError:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
return
diff --git a/examples/purchase/send_stars.py b/examples/purchase/send_stars.py
index 76b616a..dbe5664 100644
--- a/examples/purchase/send_stars.py
+++ b/examples/purchase/send_stars.py
@@ -3,6 +3,8 @@ Example: purchase Telegram Stars for a user.
Amount must be an integer between 50 and 1 000 000.
Set show_sender=False to send anonymously.
+payment_method can be "ton" or "usdt_ton".
+Username can be "@username", "username", or "https://t.me/username".
"""
import asyncio
@@ -24,14 +26,20 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
-USERNAME = "@username"
+USERNAME = "https://t.me/username"
AMOUNT = 500 # 50–1 000 000 stars
+PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
try:
- result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True)
+ result = await client.purchase_stars(
+ USERNAME,
+ amount=AMOUNT,
+ show_sender=True,
+ payment_method=PAYMENT_METHOD,
+ )
except UserNotFoundError:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
return
diff --git a/examples/purchase/topup_ton_balance.py b/examples/purchase/topup_ton_balance.py
index f09d0f3..c4fa58f 100644
--- a/examples/purchase/topup_ton_balance.py
+++ b/examples/purchase/topup_ton_balance.py
@@ -4,7 +4,7 @@ Example: top up TON to a recipient's Telegram balance.
For adding TON to a Telegram Ads account, use recharge_ads() instead.
Amount must be an integer between 1 and 1 000 000 000 TON.
-Your wallet must hold at least the top-up amount + ~0.056 TON for gas.
+Your wallet must satisfy the current minimum TON threshold and transaction cost.
"""
import asyncio
diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py
index daee3af..78c492b 100644
--- a/pyfragment/__init__.py
+++ b/pyfragment/__init__.py
@@ -15,6 +15,7 @@ from pyfragment.types import (
CookieError,
CookieResult,
FragmentAPIError,
+ # exceptions
FragmentError,
FragmentPageError,
GiftsResult,
@@ -22,9 +23,12 @@ from pyfragment.types import (
NumbersResult,
OperationError,
ParseError,
+ # literal types
+ PaymentMethod,
PremiumGiveawayResult,
PremiumResult,
StarsGiveawayResult,
+ # results
StarsResult,
TerminateSessionsResult,
TransactionError,
@@ -41,31 +45,35 @@ __version__: str = version("pyfragment")
__all__ = [
"__version__",
"FragmentClient",
- "AdsRechargeResult",
+ # results
+ "StarsResult",
+ "StarsGiveawayResult",
+ "PremiumResult",
+ "PremiumGiveawayResult",
+ "WalletInfo",
"AdsTopupResult",
+ "AdsRechargeResult",
+ "CookieResult",
"GiftsResult",
"LoginCodeResult",
"NumbersResult",
- "PremiumGiveawayResult",
- "PremiumResult",
- "StarsGiveawayResult",
- "StarsResult",
"TerminateSessionsResult",
"UsernamesResult",
- "WalletInfo",
- "ClientError",
- "ConfigurationError",
- "CookieError",
- "CookieResult",
- "FragmentAPIError",
+ # exceptions
"FragmentError",
+ "FragmentAPIError",
"FragmentPageError",
+ "ConfigurationError",
+ "UserNotFoundError",
+ "WalletError",
+ "VerificationError",
+ "TransactionError",
"AnonymousNumberError",
+ "ClientError",
+ "CookieError",
"OperationError",
"ParseError",
- "TransactionError",
"UnexpectedError",
- "UserNotFoundError",
- "VerificationError",
- "WalletError",
+ # literal types
+ "PaymentMethod",
]
diff --git a/pyfragment/client.py b/pyfragment/client.py
index c542d4c..992d033 100644
--- a/pyfragment/client.py
+++ b/pyfragment/client.py
@@ -36,6 +36,7 @@ from pyfragment.types.constants import (
FRAGMENT_BASE_URL,
REQUIRED_COOKIE_KEYS,
SUPPORTED_WALLET_VERSIONS,
+ PaymentMethod,
WalletVersion,
)
from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers
@@ -125,31 +126,45 @@ class FragmentClient:
def __repr__(self) -> str:
return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)"
- async def purchase_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult:
+ async def purchase_premium(
+ self,
+ username: str,
+ months: int,
+ show_sender: bool = True,
+ payment_method: PaymentMethod = "ton",
+ ) -> PremiumResult:
"""Gift Telegram Premium to a user.
Args:
- username: Recipient's Telegram username (with or without ``@``).
+ username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
months: Duration — ``3``, ``6``, or ``12``.
show_sender: Show your name as the sender. Defaults to ``True``.
+ payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
"""
- return await purchase_premium(self, username, months, show_sender)
+ return await purchase_premium(self, username, months, show_sender, payment_method)
- async def purchase_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult:
+ async def purchase_stars(
+ self,
+ username: str,
+ amount: int,
+ show_sender: bool = True,
+ payment_method: PaymentMethod = "ton",
+ ) -> StarsResult:
"""Send Telegram Stars to a user.
Args:
- username: Recipient's Telegram username (with or without ``@``).
+ username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
amount: Number of stars — integer from ``50`` to ``1 000 000``.
show_sender: Show your name as the gift sender. Defaults to ``True``.
+ payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
"""
- return await purchase_stars(self, username, amount, show_sender)
+ return await purchase_stars(self, username, amount, show_sender, payment_method)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up TON to a recipient's Telegram balance.
@@ -178,11 +193,12 @@ class FragmentClient:
return await recharge_ads(self, account, amount)
async def get_wallet(self) -> WalletInfo:
- """Return the address, state and balance of the TON wallet.
+ """Return the address, state, and balances of the wallet.
Returns:
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
- (``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), and ``balance`` in TON.
+ (``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
+ ``ton_balance`` in TON, and ``usdt_balance`` in USDT.
"""
return await get_wallet_info(self)
@@ -191,38 +207,42 @@ class FragmentClient:
channel: str,
winners: int,
amount: int,
+ payment_method: PaymentMethod = "ton",
) -> StarsGiveawayResult:
"""Run a Telegram Stars giveaway for a channel.
Args:
- channel: Channel username (with or without ``@``).
+ channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — integer from ``1`` to ``5``.
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
+ payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
"""
- return await giveaway_stars(self, channel, winners, amount)
+ return await giveaway_stars(self, channel, winners, amount, payment_method)
async def giveaway_premium(
self,
channel: str,
winners: int,
months: int = 3,
+ payment_method: PaymentMethod = "ton",
) -> PremiumGiveawayResult:
"""Run a Telegram Premium giveaway for a channel.
Args:
- channel: Channel username (with or without ``@``).
+ channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — positive integer.
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
+ payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
"""
- return await giveaway_premium(self, channel, winners, months)
+ return await giveaway_premium(self, channel, winners, months, payment_method)
async def get_login_code(self, number: str) -> LoginCodeResult:
"""Fetch the current pending login code for an anonymous number.
diff --git a/pyfragment/methods/giveaway_premium.py b/pyfragment/methods/giveaway_premium.py
index 2c6675d..8acb0ea 100644
--- a/pyfragment/methods/giveaway_premium.py
+++ b/pyfragment/methods/giveaway_premium.py
@@ -12,8 +12,8 @@ from pyfragment.types import (
UserNotFoundError,
VerificationError,
)
-from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE
-from pyfragment.utils import get_account_info, process_transaction
+from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
+from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
@@ -24,14 +24,16 @@ async def giveaway_premium(
channel: str,
winners: int,
months: int = 3,
+ payment_method: PaymentMethod = "ton",
) -> PremiumGiveawayResult:
"""Run a Telegram Premium giveaway for a channel.
Args:
client: Authenticated :class:`FragmentClient` instance.
- channel: Channel username (with or without ``@``).
+ channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — integer from ``1`` to ``24 000``.
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
+ payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
@@ -47,6 +49,13 @@ async def giveaway_premium(
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
if months not in (3, 6, 12):
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
+ if payment_method not in SUPPORTED_PAYMENT_METHODS:
+ raise ConfigurationError(
+ ConfigurationError.INVALID_PAYMENT_METHOD.format(
+ method=payment_method,
+ supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
+ )
+ )
try:
result = await client.call(
@@ -60,9 +69,15 @@ async def giveaway_premium(
result = await client.call(
"initGiveawayPremiumRequest",
- {"recipient": recipient, "quantity": str(winners), "months": str(months)},
+ {
+ "recipient": recipient,
+ "quantity": str(winners),
+ "months": str(months),
+ "payment_method": payment_method,
+ },
page_url=PREMIUM_GIVEAWAY_PAGE,
)
+ required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
@@ -81,7 +96,12 @@ async def giveaway_premium(
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
- tx_hash = await process_transaction(client, transaction)
+ tx_hash = await process_transaction(
+ client,
+ transaction,
+ payment_method=payment_method,
+ required_payment_amount=required_payment_amount,
+ )
return PremiumGiveawayResult(
transaction_id=tx_hash,
channel=channel,
diff --git a/pyfragment/methods/giveaway_stars.py b/pyfragment/methods/giveaway_stars.py
index 06646fb..90b3dc7 100644
--- a/pyfragment/methods/giveaway_stars.py
+++ b/pyfragment/methods/giveaway_stars.py
@@ -12,8 +12,8 @@ from pyfragment.types import (
UserNotFoundError,
VerificationError,
)
-from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE
-from pyfragment.utils import get_account_info, process_transaction
+from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
+from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
@@ -24,14 +24,16 @@ async def giveaway_stars(
channel: str,
winners: int,
amount: int,
+ payment_method: PaymentMethod = "ton",
) -> StarsGiveawayResult:
"""Run a Telegram Stars giveaway for a channel.
Args:
client: Authenticated :class:`FragmentClient` instance.
- channel: Channel username (with or without ``@``).
+ channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — integer from ``1`` to ``5``.
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
+ payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
@@ -47,6 +49,13 @@ async def giveaway_stars(
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
+ if payment_method not in SUPPORTED_PAYMENT_METHODS:
+ raise ConfigurationError(
+ ConfigurationError.INVALID_PAYMENT_METHOD.format(
+ method=payment_method,
+ supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
+ )
+ )
try:
result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE)
@@ -56,9 +65,15 @@ async def giveaway_stars(
result = await client.call(
"initGiveawayStarsRequest",
- {"recipient": recipient, "quantity": str(winners), "stars": str(amount)},
+ {
+ "recipient": recipient,
+ "quantity": str(winners),
+ "stars": str(amount),
+ "payment_method": payment_method,
+ },
page_url=STARS_GIVEAWAY_PAGE,
)
+ required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
@@ -77,7 +92,12 @@ async def giveaway_stars(
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
- tx_hash = await process_transaction(client, transaction)
+ tx_hash = await process_transaction(
+ client,
+ transaction,
+ payment_method=payment_method,
+ required_payment_amount=required_payment_amount,
+ )
return StarsGiveawayResult(
transaction_id=tx_hash,
channel=channel,
diff --git a/pyfragment/methods/purchase_premium.py b/pyfragment/methods/purchase_premium.py
index 771cf8e..5924fe1 100644
--- a/pyfragment/methods/purchase_premium.py
+++ b/pyfragment/methods/purchase_premium.py
@@ -13,21 +13,28 @@ from pyfragment.types import (
UserNotFoundError,
VerificationError,
)
-from pyfragment.types.constants import DEVICE, PREMIUM_PAGE
-from pyfragment.utils import get_account_info, process_transaction
+from pyfragment.types.constants import DEVICE, PREMIUM_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
+from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
-async def purchase_premium(client: FragmentClient, username: str, months: int, show_sender: bool = True) -> PremiumResult:
+async def purchase_premium(
+ client: FragmentClient,
+ username: str,
+ months: int,
+ show_sender: bool = True,
+ payment_method: PaymentMethod = "ton",
+) -> PremiumResult:
"""Gift Telegram Premium to a user.
Args:
client: Authenticated :class:`FragmentClient` instance.
- username: Recipient's Telegram username (with or without ``@``).
+ username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
months: Premium duration — ``3``, ``6``, or ``12``.
show_sender: Show your name as the gift sender. Defaults to ``True``.
+ payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
@@ -40,6 +47,13 @@ async def purchase_premium(client: FragmentClient, username: str, months: int, s
"""
if months not in (3, 6, 12):
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
+ if payment_method not in SUPPORTED_PAYMENT_METHODS:
+ raise ConfigurationError(
+ ConfigurationError.INVALID_PAYMENT_METHOD.format(
+ method=payment_method,
+ supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
+ )
+ )
try:
result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE)
@@ -52,7 +66,12 @@ async def purchase_premium(client: FragmentClient, username: str, months: int, s
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
page_url=PREMIUM_PAGE,
)
- result = await client.call("initGiftPremiumRequest", {"recipient": recipient, "months": months}, page_url=PREMIUM_PAGE)
+ result = await client.call(
+ "initGiftPremiumRequest",
+ {"recipient": recipient, "months": months, "payment_method": payment_method},
+ page_url=PREMIUM_PAGE,
+ )
+ required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
@@ -72,7 +91,12 @@ async def purchase_premium(client: FragmentClient, username: str, months: int, s
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
- tx_hash = await process_transaction(client, transaction)
+ tx_hash = await process_transaction(
+ client,
+ transaction,
+ payment_method=payment_method,
+ required_payment_amount=required_payment_amount,
+ )
return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
except FragmentError:
diff --git a/pyfragment/methods/purchase_stars.py b/pyfragment/methods/purchase_stars.py
index 923682a..44c0e1e 100644
--- a/pyfragment/methods/purchase_stars.py
+++ b/pyfragment/methods/purchase_stars.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
+import time
from typing import TYPE_CHECKING
from pyfragment.types import (
@@ -12,21 +13,24 @@ from pyfragment.types import (
UserNotFoundError,
VerificationError,
)
-from pyfragment.types.constants import DEVICE, STARS_PAGE
-from pyfragment.utils import get_account_info, process_transaction
+from pyfragment.types.constants import DEVICE, STARS_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
+from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
-async def purchase_stars(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> StarsResult:
+async def purchase_stars(
+ client: FragmentClient, username: str, amount: int, show_sender: bool = True, payment_method: PaymentMethod = "ton"
+) -> StarsResult:
"""Send Telegram Stars to a user.
Args:
client: Authenticated :class:`FragmentClient` instance.
- username: Recipient's Telegram username (with or without ``@``).
+ username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
amount: Number of Stars to send — integer from ``50`` to ``1 000 000``.
show_sender: Show your name as the gift sender. Defaults to ``True``.
+ payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
@@ -39,6 +43,13 @@ async def purchase_stars(client: FragmentClient, username: str, amount: int, sho
"""
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
+ if payment_method not in SUPPORTED_PAYMENT_METHODS:
+ raise ConfigurationError(
+ ConfigurationError.INVALID_PAYMENT_METHOD.format(
+ method=payment_method,
+ supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
+ )
+ )
try:
result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE)
@@ -46,7 +57,17 @@ async def purchase_stars(client: FragmentClient, username: str, amount: int, sho
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
- result = await client.call("initBuyStarsRequest", {"recipient": recipient, "quantity": amount}, page_url=STARS_PAGE)
+ await client.call(
+ "updateStarsBuyState",
+ {"mode": "new", "lv": "false", "dh": str(int(time.time()))},
+ page_url=STARS_PAGE,
+ )
+ result = await client.call(
+ "initBuyStarsRequest",
+ {"recipient": recipient, "quantity": amount, "payment_method": payment_method},
+ page_url=STARS_PAGE,
+ )
+ required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
@@ -66,7 +87,12 @@ async def purchase_stars(client: FragmentClient, username: str, amount: int, sho
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
- tx_hash = await process_transaction(client, transaction)
+ tx_hash = await process_transaction(
+ client,
+ transaction,
+ payment_method=payment_method,
+ required_payment_amount=required_payment_amount,
+ )
return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError:
diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py
index 52526a6..e8c7ffe 100644
--- a/pyfragment/types/__init__.py
+++ b/pyfragment/types/__init__.py
@@ -1,3 +1,4 @@
+from pyfragment.types.constants import PaymentMethod
from pyfragment.types.exceptions import (
AnonymousNumberError,
ClientError,
@@ -61,4 +62,6 @@ __all__ = [
"TerminateSessionsResult",
"UsernamesResult",
"WalletInfo",
+ # literal types
+ "PaymentMethod",
]
diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py
index d552234..af7b29d 100644
--- a/pyfragment/types/constants.py
+++ b/pyfragment/types/constants.py
@@ -5,6 +5,10 @@ from typing import Any, Literal, get_args
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
+# Payment methods
+PaymentMethod = Literal["ton", "usdt_ton"]
+SUPPORTED_PAYMENT_METHODS: frozenset[str] = frozenset(get_args(PaymentMethod))
+
# Single source of truth for supported wallet versions
WalletVersion = Literal["V4R2", "V5R1"]
SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion))
@@ -12,8 +16,12 @@ SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion))
# Wallet class map — used to resolve the correct contract from WALLET_VERSION
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
-# Minimum wallet balance required to cover TON network gas fees.
-MIN_TON_BALANCE: float = 0.056
+# Minimum TON balance threshold required for payment flows.
+MIN_TON_BALANCE: float = 0.33
+
+# USDT (TON) jetton metadata used for payment-method balance checks.
+USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
+MIN_USDT_BALANCE: float = 0.75
# Default HTTP request timeout in seconds.
DEFAULT_TIMEOUT: float = 30.0
@@ -74,12 +82,15 @@ BASE_HEADERS: dict[str, str] = {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"origin": FRAGMENT_BASE_URL,
"priority": "u=1, i",
+ "sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
+ "sec-ch-ua-mobile": "?1",
+ "sec-ch-ua-platform": '"Android"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent": (
- "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) "
- "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
+ "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36"
),
"x-requested-with": "XMLHttpRequest",
}
diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py
index 5f34a29..6f8f621 100644
--- a/pyfragment/types/exceptions.py
+++ b/pyfragment/types/exceptions.py
@@ -28,6 +28,7 @@ class ConfigurationError(ClientError):
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5."
INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24 000."
INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000."
+ INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}."
class CookieError(ClientError):
@@ -130,11 +131,10 @@ class OperationError(FragmentError):
class WalletError(OperationError):
"""Raised for TON wallet issues (connection, balance, account info)."""
- LOW_BALANCE = (
- "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required "
- "(transaction amount + {gas:.3f} TON gas reserve)."
- )
- BALANCE_CHECK_FAILED = "Failed to fetch wallet balance: {exc}"
+ LOW_TON_BALANCE = "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required."
+ LOW_USDT_BALANCE = "Insufficient USDT balance: {balance:.4f} USDT available, {required:.4f} USDT required."
+ TON_BALANCE_CHECK_FAILED = "Failed to fetch TON balance: {exc}"
+ USDT_BALANCE_CHECK_FAILED = "Failed to fetch USDT balance: {exc}"
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}"
WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}"
diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py
index 1e4cecc..7ae9017 100644
--- a/pyfragment/types/results.py
+++ b/pyfragment/types/results.py
@@ -27,10 +27,14 @@ class WalletInfo:
address: str
state: str
- balance: float
+ ton_balance: float
+ usdt_balance: float
def __repr__(self) -> str:
- return f"WalletInfo(address='{self.address}', state='{self.state}', balance={self.balance} TON)"
+ return (
+ f"WalletInfo(address='{self.address}', state='{self.state}', "
+ f"ton_balance={self.ton_balance} TON, usdt_balance={self.usdt_balance} USDT)"
+ )
@dataclass
diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py
index 45e96d7..cedd637 100644
--- a/pyfragment/utils/__init__.py
+++ b/pyfragment/utils/__init__.py
@@ -1,6 +1,6 @@
from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser
from pyfragment.utils.decoder import clean_decode
-from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code
+from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code, parse_required_payment_amount
from pyfragment.utils.http import (
execute_transaction_request,
fragment_request,
@@ -17,6 +17,7 @@ __all__ = [
"parse_auction_rows",
"parse_gift_items",
"parse_login_code",
+ "parse_required_payment_amount",
"execute_transaction_request",
"fragment_request",
"get_account_info",
diff --git a/pyfragment/utils/decoder.py b/pyfragment/utils/decoder.py
index d38a692..4285466 100644
--- a/pyfragment/utils/decoder.py
+++ b/pyfragment/utils/decoder.py
@@ -7,7 +7,7 @@ from ton_core import Cell
from pyfragment.types import ParseError
-def clean_decode(payload: str) -> str:
+def clean_decode(payload: str) -> str | Cell:
"""Decode a base64-encoded BOC payload to a plain-text comment string.
Fragment transaction payloads are BOC-serialised TVM cells. This function
@@ -18,7 +18,8 @@ def clean_decode(payload: str) -> str:
payload: Base64url-encoded BOC string (padding is added automatically).
Returns:
- Decoded comment string, or ``""`` for an empty payload.
+ Decoded comment string, ``""`` for an empty payload, or raw ``Cell``
+ when payload is a non-UTF8 binary body.
Raises:
ParseError: If the payload cannot be decoded or parsed.
@@ -28,10 +29,15 @@ def clean_decode(payload: str) -> str:
return ""
s += "=" * (-len(s) % 4)
try:
- boc = base64.b64decode(s)
+ # Fragment may return URL-safe base64 ("-"/"_") in transaction payloads.
+ boc = base64.b64decode(s, altchars=b"-_", validate=True)
cell = Cell.one_from_boc(boc)
sl = cell.begin_parse()
- sl.load_uint(32) # op code — always 0 for text comment
- return sl.load_snake_string().strip()
+ sl.load_uint(32) # op code
+ try:
+ return sl.load_snake_string().strip()
+ except UnicodeDecodeError:
+ # Some Fragment payloads are binary TVM cells rather than text comments.
+ return cell
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
diff --git a/pyfragment/utils/html.py b/pyfragment/utils/html.py
index 28defd7..d0b98b6 100644
--- a/pyfragment/utils/html.py
+++ b/pyfragment/utils/html.py
@@ -161,3 +161,12 @@ def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]:
next_offset = int(next_offset_m.group(1)) if next_offset_m else None
return items, next_offset
+
+
+def parse_required_payment_amount(init_response: dict[str, Any]) -> float | None:
+ """Extract required payment amount from init*Request response."""
+ raw_amount = init_response.get("amount")
+ try:
+ return float(str(raw_amount))
+ except (TypeError, ValueError):
+ return None
diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py
index d5c4ee6..2212690 100644
--- a/pyfragment/utils/wallet.py
+++ b/pyfragment/utils/wallet.py
@@ -8,17 +8,91 @@ from typing import TYPE_CHECKING, Any
from ton_core import NetworkGlobalID
from tonutils.clients import TonapiClient
+from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
from tonutils.exceptions import ProviderResponseError
from pyfragment.types import TransactionError, WalletError, WalletInfo
-from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES
+from pyfragment.types.constants import (
+ MIN_TON_BALANCE,
+ MIN_USDT_BALANCE,
+ USDT_TON_MASTER_ADDRESS,
+ WALLET_CLASSES,
+ PaymentMethod,
+)
from pyfragment.utils.decoder import clean_decode
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
-async def process_transaction(client: FragmentClient, transaction_data: dict[str, Any]) -> str:
+async def _get_usdt_balance(ton: Any, wallet_address: str) -> float:
+ """Return wallet USDT balance via tonutils jetton get-methods."""
+ try:
+ jetton_wallet_address = await get_wallet_address_get_method(
+ client=ton,
+ address=USDT_TON_MASTER_ADDRESS,
+ owner_address=wallet_address,
+ )
+ wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address)
+ raw_balance = int(wallet_data[0]) if wallet_data else 0
+ return float(raw_balance) / 1_000_000.0
+ except ProviderResponseError as exc:
+ # No jetton wallet deployed yet -> effectively zero USDT balance.
+ if exc.code == 404:
+ return 0.0
+ raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
+ except Exception as exc:
+ raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
+
+
+async def _check_ton_payment_balance(
+ balance_ton: float,
+ amount_ton: float,
+ required_payment_amount: float | None,
+) -> None:
+ """Validate balance requirements for TON payment method."""
+ tx_price_ton = amount_ton
+ if required_payment_amount is not None and required_payment_amount > 0:
+ tx_price_ton = max(tx_price_ton, required_payment_amount)
+
+ required_ton = max(tx_price_ton, MIN_TON_BALANCE)
+ if balance_ton < required_ton:
+ raise WalletError(
+ WalletError.LOW_TON_BALANCE.format(
+ balance=balance_ton,
+ required=required_ton,
+ )
+ )
+
+
+async def _check_usdt_payment_balance(
+ balance_ton: float,
+ required_payment_amount: float | None,
+ ton: Any,
+ wallet_address: str,
+) -> None:
+ """Validate balance requirements for USDT payment method."""
+ # USDT payment still needs TON for network fees.
+ if balance_ton < MIN_TON_BALANCE:
+ raise WalletError(
+ WalletError.LOW_TON_BALANCE.format(
+ balance=balance_ton,
+ required=MIN_TON_BALANCE,
+ )
+ )
+
+ usdt_balance = await _get_usdt_balance(ton, wallet_address)
+ required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE
+ if usdt_balance < required_usdt:
+ raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt))
+
+
+async def process_transaction(
+ client: FragmentClient,
+ transaction_data: dict[str, Any],
+ payment_method: PaymentMethod = "ton",
+ required_payment_amount: float | None = None,
+) -> str:
"""Sign and broadcast a Fragment transaction to the TON network.
Validates the payload structure, checks the wallet balance, decodes the
@@ -27,6 +101,8 @@ async def process_transaction(client: FragmentClient, transaction_data: dict[str
Args:
client: Authenticated :class:`FragmentClient` instance.
transaction_data: Raw transaction dict from ``execute_transaction_request``.
+ payment_method: Payment currency — ``"ton"`` or ``"usdt_ton"``.
+ required_payment_amount: Optional price from init*Request response.
Returns:
Normalised transaction hash string.
@@ -45,20 +121,32 @@ async def process_transaction(client: FragmentClient, transaction_data: dict[str
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
- # Check balance covers transaction amount + gas reserve
+ # Check balance covers selected payment flow requirements.
try:
await wallet.refresh()
balance_ton = wallet.balance / 1_000_000_000
- required = amount_ton + MIN_TON_BALANCE
- if balance_ton < required:
- raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton, required=required, gas=MIN_TON_BALANCE))
+ wallet_address = wallet.address.to_str(False, False)
+ if payment_method == "ton":
+ await _check_ton_payment_balance(
+ balance_ton,
+ amount_ton,
+ required_payment_amount,
+ )
+ else:
+ await _check_usdt_payment_balance(
+ balance_ton,
+ required_payment_amount,
+ ton,
+ wallet_address,
+ )
except WalletError:
raise
except Exception as exc:
- raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc
+ raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
try:
- payload = clean_decode(message["payload"])
+ raw_payload = str(message.get("payload", ""))
+ payload = clean_decode(raw_payload)
for attempt in range(3):
try:
@@ -130,7 +218,8 @@ async def get_wallet_info(client: FragmentClient) -> WalletInfo:
client: Authenticated :class:`FragmentClient` instance.
Returns:
- :class:`WalletInfo` with ``address``, ``state``, and ``balance`` in TON.
+ :class:`WalletInfo` with ``address``, ``state``, ``balance`` in TON,
+ and ``usdt_balance`` in USDT.
Raises:
WalletError: If the wallet state cannot be fetched.
@@ -140,10 +229,13 @@ async def get_wallet_info(client: FragmentClient) -> WalletInfo:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
await wallet.refresh()
+ wallet_address = wallet.address.to_str(False, False)
+ usdt_balance = await _get_usdt_balance(ton, wallet_address)
return WalletInfo(
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
state=wallet.state.value,
- balance=round(wallet.balance / 1_000_000_000, 4),
+ ton_balance=round(wallet.balance / 1_000_000_000, 4),
+ usdt_balance=round(usdt_balance, 4),
)
except Exception as exc:
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
diff --git a/pyproject.toml b/pyproject.toml
index d9fe781..15f6361 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project]
name = "pyfragment"
-version = "2026.2.1"
-description = "Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts."
+version = "2026.2.2"
+description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings."
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
@@ -14,19 +14,23 @@ keywords = [
"fragment",
"fragment-api",
"telegram",
+ "telegram-api",
"telegram-stars",
"telegram-premium",
+ "telegram-giveaway",
+ "telegram-ads",
"ton",
+ "ton-blockchain",
"tonkeeper",
"tonapi",
- "crypto",
- "blockchain",
+ "anonymous-numbers",
+ "username-auctions",
+ "gift-marketplace",
+ "crypto-payments",
+ "nft-marketplace",
"web3",
- "giveaway",
- "anonymous-number",
- "username",
- "nft",
- "async",
+ "python-client",
+ "typed",
"asyncio",
]
classifiers = [
diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py
index 8a60ac3..0541c4b 100644
--- a/tests/001_test_decode.py
+++ b/tests/001_test_decode.py
@@ -1,37 +1,62 @@
"""Tests for clean_decode() — TON BOC payload decoding."""
+import base64
import re
+from unittest.mock import patch
import pytest
+from ton_core import Cell
from pyfragment.types import ParseError
from pyfragment.utils.decoder import clean_decode
-PAYLOADS = [
+PAYLOAD_CASES = [
pytest.param(
"te6ccgEBAgEALwABTgAAAAAxMDAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmI1RQb01wegEABkM3ZQ",
+ True,
id="stars",
),
pytest.param(
"te6ccgEBAgEANAABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAxIHllYXIgCgpSZWYjcgEAEE9OQnM2cmNt",
+ True,
id="premium",
),
pytest.param(
"te6ccgEBAgEAMAABTgAAAABUZWxlZ3JhbSBhY2NvdW50IHRvcCB1cCAKClJlZiNrMXpDRQEACFkxd3g",
+ True,
id="topup",
),
+ pytest.param(
+ "te6ccgEBAgEAfgABqA-KfqVP885dhccidjC3GwgBCkiH8LM_zUu0afyGCTWJwX1mDjdlf2rMa9UoQlD4UHUAF1jLlcMomlo5RJTwl8jnDDdfdhc7EgQQWPqFQ9IjyLPCAwEASgAAAAA1MCBUZWxlZ3JhbSBTdGFycyAKClJlZiNtOUpoWndBcFE",
+ False,
+ id="real_stars_50",
+ ),
+ pytest.param(
+ "te6ccgEBAgEANgABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAzIG1vbnRocyAKClJlZgEAFCMzcFdKdGJkYnU",
+ False,
+ id="real_premium_3m",
+ ),
+ pytest.param(
+ "te6ccgEBAwEAhgABqg-KfqWibdDaYaJCPUWWgvAIAQpIh_CzP81LtGn8hgk1icF9Zg43ZX9qzGvVKEJQ-FB1ABdYy5XDKJpaOUSU8JfI5ww3X3YXOxIEEFj6hUPSI8izwgMBAU4AAAAAMTAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmIzBoZ0RmNEYCAAQ5VA",
+ False,
+ id="real_stars_100k",
+ ),
]
# Decode valid payload tests
-@pytest.mark.parametrize("payload", PAYLOADS)
-def test_decode_payload(payload: str) -> None:
+@pytest.mark.parametrize(("payload", "strict_ref"), PAYLOAD_CASES)
+def test_decode_payload(payload: str, strict_ref: bool) -> None:
result = clean_decode(payload)
- assert "Telegram" in result
- assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}"
- assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}"
+ if isinstance(result, str):
+ assert "Telegram" in result
+ if strict_ref:
+ assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}"
+ assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}"
+ else:
+ assert isinstance(result, Cell)
# Edge case tests
@@ -44,3 +69,64 @@ def test_empty_payload_returns_empty_string() -> None:
def test_invalid_payload_raises_parse_error() -> None:
with pytest.raises(ParseError):
clean_decode("!!!not-valid-base64!!!")
+
+
+def test_decode_payload_accepts_base64url_alphabet() -> None:
+ class _FakeSlice:
+ def load_uint(self, _: int) -> int:
+ return 0
+
+ def load_snake_string(self) -> str:
+ return "Telegram Stars Ref#abc"
+
+ class _FakeCell:
+ def begin_parse(self) -> _FakeSlice:
+ return _FakeSlice()
+
+ raw = b"\xfb\xef\xff\x00"
+ payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
+
+ with patch("pyfragment.utils.decoder.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
+ result = clean_decode(payload)
+
+ mocked.assert_called_once_with(raw)
+ assert result == "Telegram Stars Ref#abc"
+
+
+def test_clean_decode_returns_text_comment_when_utf8() -> None:
+ class _FakeSlice:
+ def load_uint(self, _: int) -> int:
+ return 0
+
+ def load_snake_string(self) -> str:
+ return "Telegram Premium Ref#abc"
+
+ class _FakeCell:
+ def begin_parse(self) -> _FakeSlice:
+ return _FakeSlice()
+
+ payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
+ with patch("pyfragment.utils.decoder.Cell.one_from_boc", return_value=_FakeCell()):
+ parsed = clean_decode(payload)
+
+ assert parsed == "Telegram Premium Ref#abc"
+
+
+def test_clean_decode_returns_cell_for_binary_payload() -> None:
+ class _FakeSlice:
+ def load_uint(self, _: int) -> int:
+ return 0
+
+ def load_snake_string(self) -> str:
+ raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
+
+ class _FakeCell:
+ def begin_parse(self) -> _FakeSlice:
+ return _FakeSlice()
+
+ payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
+ fake_cell: object = _FakeCell()
+ with patch("pyfragment.utils.decoder.Cell.one_from_boc", return_value=fake_cell):
+ parsed = clean_decode(payload)
+
+ assert parsed is fake_cell
diff --git a/tests/003_test_balance.py b/tests/003_test_balance.py
index 6fa91b5..d3e0ea8 100644
--- a/tests/003_test_balance.py
+++ b/tests/003_test_balance.py
@@ -62,7 +62,7 @@ def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
@pytest.mark.asyncio
async def test_sufficient_balance_broadcasts() -> None:
- wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, needs 0.556 TON
+ wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
@@ -71,7 +71,7 @@ async def test_sufficient_balance_broadcasts() -> None:
@pytest.mark.asyncio
async def test_insufficient_balance_raises() -> None:
- wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, needs 0.556 TON
+ wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, below threshold
with _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA)
@@ -80,7 +80,7 @@ async def test_insufficient_balance_raises() -> None:
@pytest.mark.asyncio
async def test_exact_minimum_balance_broadcasts() -> None:
- wallet = _make_wallet(balance_nanotons=556_000_000) # exactly 0.5 + 0.056 TON
+ wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
@@ -88,7 +88,7 @@ async def test_exact_minimum_balance_broadcasts() -> None:
@pytest.mark.asyncio
async def test_one_nanoton_below_minimum_raises() -> None:
- wallet = _make_wallet(balance_nanotons=555_999_999) # 1 nanoton below threshold
+ wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanoton below transaction amount threshold
with _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA)
@@ -138,3 +138,41 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
with pytest.raises(TransactionError, match="seqno"):
await process_transaction(_make_client(), TRANSACTION_DATA)
assert wallet.transfer.call_count == 3
+
+
+@pytest.mark.asyncio
+async def test_usdt_payment_requires_min_ton_gas_reserve() -> None:
+ wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE
+ with _patch_wallet(wallet), patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=100.0)):
+ with pytest.raises(WalletError, match="Insufficient TON balance"):
+ await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
+
+
+@pytest.mark.asyncio
+async def test_usdt_payment_checks_usdt_balance() -> None:
+ wallet = _make_wallet(balance_nanotons=1_000_000_000)
+ transaction = {
+ "transaction": {
+ "messages": [
+ {
+ "address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
+ "amount": "50000000",
+ "payload": "",
+ }
+ ]
+ },
+ "required_usdt": 12.5,
+ }
+
+ with (
+ _patch_wallet(wallet),
+ patch("pyfragment.utils.wallet.clean_decode", return_value=""),
+ patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=5.0)),
+ ):
+ with pytest.raises(WalletError, match="Insufficient USDT balance"):
+ await process_transaction(
+ _make_client(),
+ transaction,
+ payment_method="usdt_ton",
+ required_payment_amount=12.5,
+ )
diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py
index e550c3c..976ad4c 100644
--- a/tests/004_test_stars.py
+++ b/tests/004_test_stars.py
@@ -32,23 +32,27 @@ async def test_purchase_stars_float_amount(client: FragmentClient) -> None:
await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type]
+@pytest.mark.asyncio
+async def test_purchase_stars_invalid_payment_method(client: FragmentClient) -> None:
+ with pytest.raises(ConfigurationError, match="Invalid payment method"):
+ await client.purchase_stars("@user", amount=500, payment_method="btc") # type: ignore[arg-type]
+
+
# Stars purchase mocked tests
@pytest.mark.asyncio
async def test_purchase_stars_success(client: FragmentClient) -> None:
+ call_mock = AsyncMock(
+ side_effect=[
+ {"found": {"recipient": FAKE_RECIPIENT}},
+ {}, # updateStarsBuyState
+ {"req_id": FAKE_REQ_ID},
+ FAKE_TRANSACTION,
+ ]
+ )
with (
- patch.object(
- client,
- "call",
- AsyncMock(
- side_effect=[
- {"found": {"recipient": FAKE_RECIPIENT}},
- {"req_id": FAKE_REQ_ID},
- FAKE_TRANSACTION,
- ]
- ),
- ),
+ patch.object(client, "call", call_mock),
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_purchase_stars_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
):
@@ -60,6 +64,44 @@ async def test_purchase_stars_success(client: FragmentClient) -> None:
assert result.amount == 500
+@pytest.mark.asyncio
+async def test_purchase_stars_passes_payment_method(client: FragmentClient) -> None:
+ call_mock = AsyncMock(
+ side_effect=[
+ {"found": {"recipient": FAKE_RECIPIENT}},
+ {}, # updateStarsBuyState
+ {"req_id": FAKE_REQ_ID},
+ FAKE_TRANSACTION,
+ ]
+ )
+ proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
+ with (
+ patch.object(client, "call", call_mock),
+ patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
+ patch.object(_purchase_stars_mod, "process_transaction", proc_mock),
+ ):
+ await client.purchase_stars("@user", amount=500, payment_method="usdt_ton")
+
+ init_call = call_mock.await_args_list[2]
+ assert init_call.args[0] == "initBuyStarsRequest"
+ assert init_call.args[1]["payment_method"] == "usdt_ton"
+ assert proc_mock.await_args is not None
+ assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"])
+async def test_purchase_stars_accepts_query_formats(client: FragmentClient, query: str) -> None:
+ call_mock = AsyncMock(return_value={"found": {}})
+ with patch.object(client, "call", call_mock):
+ with pytest.raises(UserNotFoundError):
+ await client.purchase_stars(query, amount=500)
+
+ search_call = call_mock.await_args_list[0]
+ assert search_call.args[0] == "searchStarsRecipient"
+ assert search_call.args[1]["query"] == query
+
+
@pytest.mark.asyncio
async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
@@ -106,6 +148,12 @@ async def test_giveaway_stars_float_amount(client: FragmentClient) -> None:
await client.giveaway_stars("@channel", winners=1, amount=500.5) # type: ignore[arg-type]
+@pytest.mark.asyncio
+async def test_giveaway_stars_invalid_payment_method(client: FragmentClient) -> None:
+ with pytest.raises(ConfigurationError, match="Invalid payment method"):
+ await client.giveaway_stars("@channel", winners=1, amount=500, payment_method="btc") # type: ignore[arg-type]
+
+
# Stars giveaway mocked tests
@@ -135,6 +183,43 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None:
assert result.amount == 1000
+@pytest.mark.asyncio
+async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> None:
+ call_mock = AsyncMock(
+ side_effect=[
+ {"found": {"recipient": FAKE_RECIPIENT}},
+ {"req_id": FAKE_REQ_ID},
+ FAKE_TRANSACTION,
+ ]
+ )
+ proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
+ with (
+ patch.object(client, "call", call_mock),
+ patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
+ patch.object(_giveaway_stars_mod, "process_transaction", proc_mock),
+ ):
+ await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method="usdt_ton")
+
+ init_call = call_mock.await_args_list[1]
+ assert init_call.args[0] == "initGiveawayStarsRequest"
+ assert init_call.args[1]["payment_method"] == "usdt_ton"
+ assert proc_mock.await_args is not None
+ assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("query", ["@channel", "monk", "https://t.me/id2757542991"])
+async def test_giveaway_stars_accepts_query_formats(client: FragmentClient, query: str) -> None:
+ call_mock = AsyncMock(return_value={"found": {}})
+ with patch.object(client, "call", call_mock):
+ with pytest.raises(UserNotFoundError):
+ await client.giveaway_stars(query, winners=1, amount=500)
+
+ search_call = call_mock.await_args_list[0]
+ assert search_call.args[0] == "searchStarsGiveawayRecipient"
+ assert search_call.args[1]["query"] == query
+
+
@pytest.mark.asyncio
async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py
index ba58d55..3d111b1 100644
--- a/tests/005_test_premium.py
+++ b/tests/005_test_premium.py
@@ -26,6 +26,12 @@ async def test_purchase_premium_months_zero(client: FragmentClient) -> None:
await client.purchase_premium("@user", months=0)
+@pytest.mark.asyncio
+async def test_purchase_premium_invalid_payment_method(client: FragmentClient) -> None:
+ with pytest.raises(ConfigurationError, match="Invalid payment method"):
+ await client.purchase_premium("@user", months=3, payment_method="btc") # type: ignore[arg-type]
+
+
# Premium purchase mocked tests
@@ -55,6 +61,44 @@ async def test_purchase_premium_success(client: FragmentClient) -> None:
assert result.amount == 3
+@pytest.mark.asyncio
+async def test_purchase_premium_passes_payment_method(client: FragmentClient) -> None:
+ call_mock = AsyncMock(
+ side_effect=[
+ {"found": {"recipient": FAKE_RECIPIENT}},
+ {}, # updatePremiumState
+ {"req_id": FAKE_REQ_ID},
+ FAKE_TRANSACTION,
+ ]
+ )
+ proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
+ with (
+ patch.object(client, "call", call_mock),
+ patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
+ patch.object(_purchase_premium_mod, "process_transaction", proc_mock),
+ ):
+ await client.purchase_premium("@user", months=6, payment_method="usdt_ton")
+
+ init_call = call_mock.await_args_list[2]
+ assert init_call.args[0] == "initGiftPremiumRequest"
+ assert init_call.args[1]["payment_method"] == "usdt_ton"
+ assert proc_mock.await_args is not None
+ assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"])
+async def test_purchase_premium_accepts_query_formats(client: FragmentClient, query: str) -> None:
+ call_mock = AsyncMock(return_value={"found": {}})
+ with patch.object(client, "call", call_mock):
+ with pytest.raises(UserNotFoundError):
+ await client.purchase_premium(query, months=6)
+
+ search_call = call_mock.await_args_list[0]
+ assert search_call.args[0] == "searchPremiumGiftRecipient"
+ assert search_call.args[1]["query"] == query
+
+
@pytest.mark.asyncio
async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
@@ -89,6 +133,12 @@ async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None:
await client.giveaway_premium("@channel", winners=10, months=5)
+@pytest.mark.asyncio
+async def test_giveaway_premium_invalid_payment_method(client: FragmentClient) -> None:
+ with pytest.raises(ConfigurationError, match="Invalid payment method"):
+ await client.giveaway_premium("@channel", winners=10, months=3, payment_method="btc") # type: ignore[arg-type]
+
+
# Premium giveaway mocked tests
@@ -118,6 +168,43 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None:
assert result.amount == 3
+@pytest.mark.asyncio
+async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> None:
+ call_mock = AsyncMock(
+ side_effect=[
+ {"found": {"recipient": FAKE_RECIPIENT}},
+ {"req_id": FAKE_REQ_ID},
+ FAKE_TRANSACTION,
+ ]
+ )
+ proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
+ with (
+ patch.object(client, "call", call_mock),
+ patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
+ patch.object(_giveaway_premium_mod, "process_transaction", proc_mock),
+ ):
+ await client.giveaway_premium("@channel", winners=10, months=6, payment_method="usdt_ton")
+
+ init_call = call_mock.await_args_list[1]
+ assert init_call.args[0] == "initGiveawayPremiumRequest"
+ assert init_call.args[1]["payment_method"] == "usdt_ton"
+ assert proc_mock.await_args is not None
+ assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("query", ["@channel", "monk", "https://t.me/id2757542991"])
+async def test_giveaway_premium_accepts_query_formats(client: FragmentClient, query: str) -> None:
+ call_mock = AsyncMock(return_value={"found": {}})
+ with patch.object(client, "call", call_mock):
+ with pytest.raises(UserNotFoundError):
+ await client.giveaway_premium(query, winners=10, months=3)
+
+ search_call = call_mock.await_args_list[0]
+ assert search_call.args[0] == "searchPremiumGiveawayRecipient"
+ assert search_call.args[1]["query"] == query
+
+
@pytest.mark.asyncio
async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
diff --git a/tests/007_test_wallet.py b/tests/007_test_wallet.py
index d13c081..fbfb403 100644
--- a/tests/007_test_wallet.py
+++ b/tests/007_test_wallet.py
@@ -1,4 +1,4 @@
-"""Unit tests for get_wallet() — wallet address and TON balance lookup."""
+"""Unit tests for get_wallet() — wallet address/state with separate TON and USDT balances."""
from unittest.mock import AsyncMock, MagicMock, patch
@@ -7,7 +7,7 @@ import pytest
from pyfragment import FragmentClient, WalletInfo
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
-# Wallet mocked tests
+# Wallet mocked tests (TON and USDT balances are returned separately)
@pytest.mark.asyncio
@@ -21,6 +21,7 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
+ patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=12.3456)),
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
@@ -31,7 +32,8 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
assert isinstance(result, WalletInfo)
assert result.address == FAKE_ADDRESS
assert result.state == "active"
- assert result.balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
+ assert result.ton_balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
+ assert result.usdt_balance == 12.3456
@pytest.mark.asyncio
@@ -45,6 +47,7 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
+ patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=0.0)),
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
@@ -52,5 +55,6 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
result = await client.get_wallet()
- assert result.balance == 0.0
+ assert result.ton_balance == 0.0
+ assert result.usdt_balance == 0.0
assert result.state == "uninit"
diff --git a/tests/015_test_payment_amount.py b/tests/015_test_payment_amount.py
new file mode 100644
index 0000000..51eff63
--- /dev/null
+++ b/tests/015_test_payment_amount.py
@@ -0,0 +1,21 @@
+"""Unit tests for init payment amount parsing."""
+
+from pyfragment.utils.html import parse_required_payment_amount
+
+
+def test_parse_required_payment_amount_ton_uses_amount() -> None:
+ init_response = {"amount": "0.326"}
+ assert parse_required_payment_amount(init_response) == 0.326
+
+
+def test_parse_required_payment_amount_usdt_uses_amount() -> None:
+ init_response = {
+ "amount": "0.00075",
+ "content": '
0.75',
+ }
+ assert parse_required_payment_amount(init_response) == 0.00075
+
+
+def test_parse_required_payment_amount_usdt_falls_back_to_amount() -> None:
+ init_response = {"amount": "1.25", "content": "
no usd icon
"}
+ assert parse_required_payment_amount(init_response) == 1.25