feat: add wallet management features and enhance error handling; update tests and examples

This commit is contained in:
bohd4nx
2026-03-15 22:41:52 +02:00
parent 8135a9da7e
commit d8f0240807
13 changed files with 225 additions and 18 deletions
+2 -2
View File
@@ -2,9 +2,9 @@ name: Lint
on: on:
push: push:
branches: [master] branches: ["**"]
pull_request: pull_request:
branches: [master] branches: ["**"]
jobs: jobs:
lint: lint:
+3 -1
View File
@@ -2,7 +2,9 @@ name: Publish to PyPI
on: on:
push: push:
branches: [master] branches: ["**"]
pull_request:
branches: ["**"]
jobs: jobs:
build: build:
-1
View File
@@ -23,7 +23,6 @@ logs/
# System files # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
cookies.json
# Testing & tooling artifacts # Testing & tooling artifacts
.hypothesis/ .hypothesis/
+1
View File
@@ -97,6 +97,7 @@ See the [`examples/`](examples/) folder for ready-to-run scripts.
| `gift_premium(username, months, show_sender=True)` | `PremiumResult` | Gift Telegram Premium subscription | `months`: 3, 6, or 12 | | `gift_premium(username, months, show_sender=True)` | `PremiumResult` | Gift Telegram Premium subscription | `months`: 3, 6, or 12 |
| `gift_stars(username, amount, show_sender=True)` | `StarsResult` | Gift Telegram Stars | `amount`: 501,000,000 | | `gift_stars(username, amount, show_sender=True)` | `StarsResult` | Gift Telegram Stars | `amount`: 501,000,000 |
| `topup_ton(username, amount, show_sender=True)` | `AdsTopupResult` | Top up Telegram Ads balance | `amount`: 11,000,000,000 | | `topup_ton(username, amount, show_sender=True)` | `AdsTopupResult` | Top up Telegram Ads balance | `amount`: 11,000,000,000 |
| `get_wallet()` | `WalletInfo` | Get wallet address, state, balance | — |
--- ---
@@ -27,9 +27,13 @@ async def main() -> None:
wallet_version="V5R1", # or "V4R2" wallet_version="V5R1", # or "V4R2"
) )
wallet = await client.get_wallet()
print("FragmentClient initialized") print("FragmentClient initialized")
print(" %-16s %s" % ("Wallet version:", client.wallet_version)) print(" %-16s %s" % ("Wallet version:", client.wallet_version))
print(" %-16s %s..." % ("API key:", client.api_key[:8])) print(" %-16s %s" % ("Address:", wallet.address))
print(" %-16s %s" % ("State:", wallet.state))
print(" %-16s %s TON" % ("Balance:", wallet.balance))
if __name__ == "__main__": if __name__ == "__main__":
+7
View File
@@ -3,6 +3,8 @@
# This source code is licensed under the MIT License found in the # This source code is licensed under the MIT License found in the
# LICENSE file in the root directory of this source tree. # LICENSE file in the root directory of this source tree.
from importlib.metadata import version
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
from pyfragment.types import ( from pyfragment.types import (
AdsTopupResult, AdsTopupResult,
@@ -21,13 +23,18 @@ from pyfragment.types import (
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
WalletError, WalletError,
WalletInfo,
) )
__version__: str = version("pyfragment")
__all__ = [ __all__ = [
"__version__",
"FragmentClient", "FragmentClient",
"AdsTopupResult", "AdsTopupResult",
"PremiumResult", "PremiumResult",
"StarsResult", "StarsResult",
"WalletInfo",
"ClientError", "ClientError",
"ConfigurationError", "ConfigurationError",
"CookieError", "CookieError",
+16
View File
@@ -11,8 +11,10 @@ from pyfragment.types import (
CookieError, CookieError,
PremiumResult, PremiumResult,
StarsResult, StarsResult,
WalletInfo,
WalletVersion, WalletVersion,
) )
from pyfragment.utils.wallet import get_wallet_info
class FragmentClient: class FragmentClient:
@@ -36,6 +38,7 @@ class FragmentClient:
api_key="AAABBB...", api_key="AAABBB...",
cookies={"stel_ssid": "...", "stel_dt": "...", ...}, cookies={"stel_ssid": "...", "stel_dt": "...", ...},
) )
print(await client.get_wallet())
result = await client.gift_premium("@username", months=6) result = await client.gift_premium("@username", months=6)
print(result.transaction_id) print(result.transaction_id)
""" """
@@ -51,6 +54,10 @@ class FragmentClient:
if missing: if missing:
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing))) raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
word_count = len(seed.split())
if word_count not in (12, 18, 24):
raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count))
if isinstance(cookies, str): if isinstance(cookies, str):
try: try:
cookies = json.loads(cookies) cookies = json.loads(cookies)
@@ -112,3 +119,12 @@ class FragmentClient:
:class:`AdsTopupResult` with ``transaction_id``, ``username``, ``amount``, ``timestamp``. :class:`AdsTopupResult` with ``transaction_id``, ``username``, ``amount``, ``timestamp``.
""" """
return await topup_ton(self, username, amount, show_sender) return await topup_ton(self, username, amount, show_sender)
async def get_wallet(self) -> WalletInfo:
"""Return the address, state and balance of the TON wallet.
Returns:
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
(``"active"``, ``"uninit"``, or ``"frozen"``), and ``balance`` in TON.
"""
return await get_wallet_info(self)
+2 -1
View File
@@ -25,7 +25,7 @@ from pyfragment.types.exceptions import (
VerificationError, VerificationError,
WalletError, WalletError,
) )
from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult, WalletInfo
__all__ = [ __all__ = [
# constants # constants
@@ -58,4 +58,5 @@ __all__ = [
"AdsTopupResult", "AdsTopupResult",
"PremiumResult", "PremiumResult",
"StarsResult", "StarsResult",
"WalletInfo",
] ]
+3 -1
View File
@@ -11,6 +11,7 @@ class ConfigurationError(ClientError):
MISSING_VARS = "Missing required parameter(s): {keys}." MISSING_VARS = "Missing required parameter(s): {keys}."
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}." UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
INVALID_MNEMONIC = "Invalid mnemonic: got {count} words, expected 12, 18, or 24."
INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months." INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months."
INVALID_STARS_AMOUNT = "Amount must be an integer between 50 and 1 000 000 stars." INVALID_STARS_AMOUNT = "Amount must be an integer between 50 and 1 000 000 stars."
INVALID_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON." INVALID_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON."
@@ -78,9 +79,10 @@ class OperationError(FragmentError):
class WalletError(OperationError): class WalletError(OperationError):
"""Raised for TON wallet issues (connection, balance, account info).""" """Raised for TON wallet issues (connection, balance, account info)."""
LOW_BALANCE = "TON wallet balance is too low: {balance:.2f} TON. Minimum required is 0.056 TON." LOW_BALANCE = "TON wallet balance is too low: {balance:.4f} TON available, {required:.4f} TON required."
BALANCE_CHECK_FAILED = "Wallet balance check failed: {exc}" BALANCE_CHECK_FAILED = "Wallet balance check failed: {exc}"
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info: {exc}" ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info: {exc}"
WALLET_INFO_FAILED = "Failed to retrieve wallet info: {exc}"
class UnexpectedError(OperationError): class UnexpectedError(OperationError):
+10 -1
View File
@@ -1,7 +1,16 @@
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
__all__ = ["AdsTopupResult", "PremiumResult", "StarsResult"] __all__ = ["AdsTopupResult", "PremiumResult", "StarsResult", "WalletInfo"]
@dataclass
class WalletInfo:
"""Wallet state returned by :meth:`FragmentClient.get_wallet`."""
address: str
state: str
balance: float
@dataclass @dataclass
+36 -10
View File
@@ -5,16 +5,13 @@ from tonutils.clients import TonapiClient
from tonutils.types import NetworkGlobalID from tonutils.types import NetworkGlobalID
from pyfragment.types import MIN_TON_BALANCE, WALLET_CLASSES, TransactionError, WalletError from pyfragment.types import MIN_TON_BALANCE, WALLET_CLASSES, TransactionError, WalletError
from pyfragment.types.results import WalletInfo
from pyfragment.utils.decoder import clean_decode from pyfragment.utils.decoder import clean_decode
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
def _init_ton_client(client: "FragmentClient") -> TonapiClient:
return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key)
async def process_transaction(client: "FragmentClient", transaction_data: dict) -> str: async def process_transaction(client: "FragmentClient", transaction_data: dict) -> str:
"""Sign and broadcast a Fragment transaction to the TON network. """Sign and broadcast a Fragment transaction to the TON network.
@@ -35,26 +32,29 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict)
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]: if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
raise TransactionError(TransactionError.INVALID_PAYLOAD) raise TransactionError(TransactionError.INVALID_PAYLOAD)
message = transaction_data["transaction"]["messages"][0]
amount_ton = int(message["amount"]) / 1_000_000_000
# TODO: Investigate 406 'inbound external message rejected before smart-contract execution'. # TODO: Investigate 406 'inbound external message rejected before smart-contract execution'.
# This happens when the previous transaction's seqno hasn't been confirmed on-chain yet, # This happens when the previous transaction's seqno hasn't been confirmed on-chain yet,
# causing the wallet contract to reject the new message. # causing the wallet contract to reject the new message.
async with _init_ton_client(client) as ton: async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version] wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
# Check balance before broadcasting # Check balance covers transaction amount + gas reserve
try: try:
await wallet.refresh() await wallet.refresh()
balance_ton = wallet.balance / 1_000_000_000 balance_ton = wallet.balance / 1_000_000_000
if balance_ton < MIN_TON_BALANCE: required = amount_ton + MIN_TON_BALANCE
raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton)) if balance_ton < required:
raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton, required=required))
except WalletError: except WalletError:
raise raise
except Exception as exc: except Exception as exc:
raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc
try: try:
message = transaction_data["transaction"]["messages"][0]
payload = clean_decode(message["payload"]) payload = clean_decode(message["payload"])
result = await wallet.transfer( result = await wallet.transfer(
@@ -85,7 +85,7 @@ async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
Raises: Raises:
WalletError: If account info cannot be retrieved. WalletError: If account info cannot be retrieved.
""" """
async with _init_ton_client(client) as ton: async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try: try:
wallet_cls = WALLET_CLASSES[client.wallet_version] wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
@@ -98,3 +98,29 @@ async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
} }
except Exception as exc: except Exception as exc:
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
async def get_wallet_info(client: "FragmentClient") -> "WalletInfo":
"""Return the address, state and balance of the TON wallet.
Args:
client: Authenticated :class:`FragmentClient` instance.
Returns:
:class:`WalletInfo` with ``address``, ``state``, and ``balance`` in TON.
Raises:
WalletError: If the wallet state cannot be fetched.
"""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
await wallet.refresh()
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),
)
except Exception as exc:
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
+13
View File
@@ -79,3 +79,16 @@ def test_whitespace_cookie_value_raises() -> None:
bad = {**VALID_COOKIES, "stel_ton_token": " "} bad = {**VALID_COOKIES, "stel_ton_token": " "}
with pytest.raises(CookieError): with pytest.raises(CookieError):
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad) FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad)
def test_invalid_mnemonic_length_raises() -> None:
bad_seed = " ".join(["word"] * 23)
with pytest.raises(ConfigurationError):
FragmentClient(seed=bad_seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
def test_valid_mnemonic_lengths() -> None:
for length in (12, 18, 24):
seed = " ".join(["abandon"] * (length - 1) + ["about"])
client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
assert len(client.seed.split()) == length
+127
View File
@@ -0,0 +1,127 @@
"""Unit tests for process_transaction() — balance checks before broadcast."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pyfragment.types import WalletError
from pyfragment.utils.wallet import process_transaction
VALID_SEED = "abandon " * 23 + "about"
# Minimal transaction payload: 0.5 TON = 500_000_000 nanotons
TRANSACTION_DATA = {
"transaction": {
"messages": [
{
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
"amount": "500000000",
"payload": "",
}
]
}
}
def _make_client(api_key: str = "test_key") -> MagicMock:
client = MagicMock()
client.api_key = api_key
client.seed = VALID_SEED.split()
client.wallet_version = "V5R1"
return client
def _make_wallet(balance_nanotons: int) -> MagicMock:
wallet = MagicMock()
wallet.refresh = AsyncMock()
wallet.balance = balance_nanotons
wallet.transfer = AsyncMock(return_value=MagicMock(normalized_hash="abc123"))
return wallet
@pytest.mark.asyncio
async def test_sufficient_balance_broadcasts() -> None:
# 0.5 TON amount + 0.056 TON gas = 0.556 TON required; wallet has 1 TON
client = _make_client()
wallet = _make_wallet(balance_nanotons=1_000_000_000)
with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"),
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
result = await process_transaction(client, TRANSACTION_DATA)
assert result == "abc123"
wallet.transfer.assert_called_once()
@pytest.mark.asyncio
async def test_insufficient_balance_raises_wallet_error() -> None:
# wallet has 0.1 TON, needs 0.556 TON
client = _make_client()
wallet = _make_wallet(balance_nanotons=100_000_000)
with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
with pytest.raises(WalletError, match="required"):
await process_transaction(client, TRANSACTION_DATA)
wallet.transfer.assert_not_called()
@pytest.mark.asyncio
async def test_exactly_minimum_balance_broadcasts() -> None:
# exactly amount + gas: 500_000_000 + 56_000_000 = 556_000_000 nanotons
client = _make_client()
wallet = _make_wallet(balance_nanotons=556_000_000)
with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"),
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
result = await process_transaction(client, TRANSACTION_DATA)
assert result == "abc123"
@pytest.mark.asyncio
async def test_one_nanoton_below_minimum_raises() -> None:
# 556_000_000 - 1 nanoton: just below threshold
client = _make_client()
wallet = _make_wallet(balance_nanotons=555_999_999)
with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
with pytest.raises(WalletError, match="required"):
await process_transaction(client, TRANSACTION_DATA)
@pytest.mark.asyncio
async def test_invalid_payload_raises_transaction_error() -> None:
from pyfragment.types import TransactionError
client = _make_client()
with pytest.raises(TransactionError):
await process_transaction(client, {"transaction": {}})