diff --git a/.env.example b/.env.example
deleted file mode 100644
index 806b019..0000000
--- a/.env.example
+++ /dev/null
@@ -1,11 +0,0 @@
-# Fragment.com cookies - copy from browser after login (Header String format)
-# Hash is now fetched dynamically
-
-# TON wallet seed phrase - 12 or 24 words separated by spaces
-SEED = "your_ton_wallet_seed_phrase_here"
-
-# TON API key - get from https://tonconsole.com
-API_KEY = "your_ton_api_key_here"
-
-# TON wallet contract version: V4R2 or V5R1 (default: V5R1)
-WALLET_VERSION = "V5R1"
diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml
new file mode 100644
index 0000000..8006d94
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.yaml
@@ -0,0 +1,101 @@
+name: Bug report
+description: Report an issue or unexpected behavior in pyfragment.
+labels:
+ - bug
+body:
+ - type: checkboxes
+ attributes:
+ label: Checklist
+ options:
+ - label: I am sure the error is coming from pyfragment code
+ required: true
+ - label: I have searched the issue tracker for similar bug reports, including closed ones
+ required: true
+
+ - type: markdown
+ attributes:
+ value: |
+ ## Context
+ Please provide as much detail as possible to help us reproduce and fix the issue.
+
+ - type: input
+ attributes:
+ label: Operating system
+ placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
+ validations:
+ required: true
+
+ - type: input
+ attributes:
+ label: Python version
+ description: Run `python --version` inside your virtualenv
+ placeholder: e.g. 3.12.3
+ validations:
+ required: true
+
+ - type: input
+ attributes:
+ label: pyfragment version
+ description: Run `pip show pyfragment` inside your virtualenv
+ placeholder: e.g. 2026.1.0
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Expected behavior
+ description: Describe what you expected to happen.
+ placeholder: e.g. Stars should be purchased and StarsResult returned.
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Current behavior
+ description: Describe what is actually happening.
+ placeholder: e.g. ParseError is raised with status 400.
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Steps to reproduce
+ description: Minimal steps that reproduce the issue.
+ placeholder: |
+ 1. Create FragmentClient with valid credentials
+ 2. Call purchase_stars("@username", amount=100)
+ 3. See error
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Code example
+ description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
+ placeholder: |
+ import asyncio
+ from pyfragment import FragmentClient
+
+ async def main():
+ client = FragmentClient(...)
+ result = await client.purchase_stars("@username", amount=100)
+
+ asyncio.run(main())
+ render: python
+
+ - type: textarea
+ attributes:
+ label: Traceback / logs
+ description: Paste the full traceback or relevant logs.
+ placeholder: |
+ Traceback (most recent call last):
+ File "main.py", line 7, in main
+ ...
+ pyfragment.types.ParseError: ...
+ render: sh
+
+ - type: textarea
+ attributes:
+ label: Additional information
+ description: Anything else that might help us diagnose the problem.
+ placeholder: e.g. Only happens with V5R1 wallet version.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..ea24733
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Ask a question or start a discussion
+ url: https://github.com/bohd4nx/pyfragment/discussions
+ about: General questions, ideas, and community help go here — not in the issue tracker.
diff --git a/.github/ISSUE_TEMPLATE/feature.yaml b/.github/ISSUE_TEMPLATE/feature.yaml
new file mode 100644
index 0000000..e2a26c4
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature.yaml
@@ -0,0 +1,51 @@
+name: Feature request
+description: Suggest an improvement or new feature for pyfragment.
+labels:
+ - enhancement
+body:
+ - type: dropdown
+ attributes:
+ label: pyfragment version
+ description: Which version are you running?
+ options:
+ - latest
+ - older
+ - n/a
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Problem
+ description: Is your request related to a specific problem? Describe it.
+ placeholder: e.g. There is no way to check my current TON balance before sending.
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Proposed solution
+ description: Describe what you would like to see added or changed.
+ placeholder: e.g. Add a get_balance() method to FragmentClient.
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Alternatives considered
+ description: Any workarounds or alternative approaches you have thought of.
+ placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
+
+ - type: textarea
+ attributes:
+ label: Code example
+ description: A short example demonstrating the desired API, if applicable.
+ placeholder: |
+ balance = await client.get_balance()
+ print(balance.ton)
+ render: python
+
+ - type: textarea
+ attributes:
+ label: Additional information
+ description: Any other context, screenshots, or references.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..e00c570
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,34 @@
+# Description
+
+Please include a summary of the change and which issue is fixed.
+Include relevant motivation and context.
+
+Fixes # (issue)
+
+## Type of change
+
+- [ ] Documentation (typos, examples, or any docs update)
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to change)
+- [ ] This change requires a documentation update
+
+## How has this been tested?
+
+Describe the tests you ran to verify the change and list any relevant details.
+
+- [ ] Existing tests pass (`pytest`)
+- [ ] New tests added for this change
+
+**Test configuration:**
+* OS:
+* Python version:
+* pyfragment version:
+
+## Checklist
+
+- [ ] My code follows the style guidelines of this project
+- [ ] I have performed a self-review of my own code
+- [ ] I have updated documentation where necessary
+- [ ] I have added tests that prove my fix or feature works
+- [ ] All new and existing tests pass locally
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 25b94b6..6388e89 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -8,3 +8,12 @@ updates:
open-pull-requests-limit: 5
labels:
- "dependencies"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "monday"
+ open-pull-requests-limit: 5
+ labels:
+ - "dependencies"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..57e35cb
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,50 @@
+name: CI
+
+on:
+ push:
+ branches: ["**"]
+ pull_request:
+ branches: ["**"]
+
+jobs:
+ lint:
+ name: Lint & Format
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v6.0.2
+
+ - uses: actions/setup-python@v6.2.0
+ with:
+ python-version: "3.12"
+
+ - uses: astral-sh/setup-uv@v7.5.0
+
+ - run: uv pip install --system ".[dev]"
+
+ - run: ruff check .
+
+ - run: black --check . --target-version py312
+
+ test:
+ name: Tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v6.0.2
+
+ - uses: actions/setup-python@v6.2.0
+ with:
+ python-version: "3.12"
+
+ - uses: astral-sh/setup-uv@v7.5.0
+
+ - run: uv pip install --system ".[dev]"
+
+ - name: Write cookies.json
+ if: ${{ env.COOKIES_JSON != '' }}
+ run: echo "$COOKIES_JSON" > cookies.json
+ env:
+ COOKIES_JSON: ${{ secrets.COOKIES_JSON }}
+
+ - run: pytest
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..1b575e9
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,101 @@
+name: Publish
+
+on:
+ workflow_run:
+ workflows: ["CI"]
+ types: [completed]
+ branches: [master]
+
+jobs:
+ version-check:
+ name: Version Check
+ if: github.event.workflow_run.conclusion == 'success'
+ runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.version.outputs.value }}
+ is-new: ${{ steps.tag.outputs.is-new }}
+
+ steps:
+ - uses: actions/checkout@v6.0.2
+ with:
+ fetch-depth: 0
+
+ - name: Read version
+ id: version
+ run: |
+ value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
+ echo "value=$value" >> $GITHUB_OUTPUT
+
+ - name: Check tag
+ id: tag
+ run: |
+ if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
+ echo "is-new=false" >> $GITHUB_OUTPUT
+ else
+ echo "is-new=true" >> $GITHUB_OUTPUT
+ fi
+
+ build:
+ name: Build
+ needs: version-check
+ if: needs.version-check.outputs.is-new == 'true'
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v6.0.2
+
+ - uses: actions/setup-python@v6.2.0
+ with:
+ python-version: "3.12"
+
+ - uses: astral-sh/setup-uv@v7.5.0
+
+ - run: uv build
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: dist
+ path: dist/*
+
+ publish:
+ name: Publish to PyPI
+ needs: [version-check, build]
+ runs-on: ubuntu-latest
+ environment:
+ name: pypi
+ url: https://pypi.org/project/pyfragment/
+ permissions:
+ id-token: write
+
+ steps:
+ - uses: actions/download-artifact@v8.0.1
+ with:
+ name: dist
+ path: dist
+
+ - uses: pypa/gh-action-pypi-publish@v1.13.0
+
+ release:
+ name: GitHub Release
+ needs: [version-check, build]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - uses: actions/checkout@v6.0.2
+ with:
+ fetch-depth: 0
+
+ - uses: actions/download-artifact@v8.0.1
+ with:
+ name: dist
+ path: dist
+
+ - uses: softprops/action-gh-release@v2.6.1
+ with:
+ tag_name: v${{ needs.version-check.outputs.version }}
+ name: v${{ needs.version-check.outputs.version }}
+ files: dist/*
+ generate_release_notes: true
+ make_latest: true
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
deleted file mode 100644
index d698a55..0000000
--- a/.github/workflows/tests.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-name: Tests
-
-on:
- push:
- branches: ["**"]
- pull_request:
- branches: ["**"]
-
-jobs:
- test:
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v6
-
- - uses: actions/setup-python@v6
- with:
- python-version: "3.12"
- cache: "pip"
-
- - name: Install dependencies
- run: pip install -r requirements.txt pytest pytest-asyncio
-
- - name: Write cookies.json
- if: ${{ env.COOKIES_JSON != '' }}
- run: echo "$COOKIES_JSON" > cookies.json
- env:
- COOKIES_JSON: ${{ secrets.COOKIES_JSON }}
-
- - name: Run tests
- run: pytest
diff --git a/.gitignore b/.gitignore
index 9cb8f25..cda7104 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,4 +23,17 @@ logs/
# System files
.DS_Store
Thumbs.db
-cookies.json
+
+# Testing & tooling artifacts
+.hypothesis/
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.coverage
+htmlcov/
+demo.run.py
+
+# Build & distribution
+dist/
+build/
+*.egg-info/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e9ff67c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,24 @@
+# Changelog
+
+All notable changes to pyfragment are documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MICRO`).
+
+---
+
+## [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)
+- `purchase_stars(username, amount)` — send Telegram Stars to any user (50–1,000,000)
+- `topup_ton(username, amount)` — top up TON Ads balance (1–1,000,000,000 TON)
+- `get_wallet()` — fetch wallet address and balance
+- Support for TON wallet versions `V4R2` and `V5R1`
+- Structured exception hierarchy (`FragmentError`, `ConfigurationError`, `CookieError`, etc.)
+- `py.typed` marker — full PEP 561 typing support for type-checkers
+- `__repr__` on all result types for readable debug output
+
+[2026.0.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.1
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..a85f6ec
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 bohd4nx
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 280f9a1..ccb7c8b 100644
--- a/README.md
+++ b/README.md
@@ -1,71 +1,117 @@
",
- "username": "@username",
- "amount": 10, # or "months" for Premium
- "timestamp": 1741234567
- }
-}
-```
-
-**Return format** (on failure):
-
-```python
-{
- "success": False,
- "error": "Telegram user '@unknown' was not found on Fragment."
-}
-```
-
-### Supported Operations
-
-| Operation | Function | Parameters | Limits |
-| ------------------ | ----------------------------------------------------- | ----------------------------------- | ------------------- |
-| **TON Topup** | `topup_ton(username, amount, show_sender=True)` | Username, TON amount, show sender | 1–1,000,000,000 TON |
-| **Premium Gift** | `buy_premium(username, months, show_sender=True)` | Username, duration, show sender | 3, 6, or 12 months |
-| **Stars Purchase** | `buy_stars(username, amount, show_sender=True)` | Username, Stars amount, show sender | 50–1,000,000 Stars |
-
-Usernames can be passed with or without `@`.
+---
diff --git a/app/core/__init__.py b/app/core/__init__.py
deleted file mode 100644
index c31b827..0000000
--- a/app/core/__init__.py
+++ /dev/null
@@ -1,44 +0,0 @@
-from app.core.config import config
-from app.core.constants import (
- ADS_PAGE,
- BASE_HEADERS,
- DEVICE,
- PREMIUM_PAGE,
- STARS_PAGE,
- WALLET_CLASSES,
- WalletVersion,
-)
-from app.core.cookies import load_cookies
-from app.core.exceptions import (
- ConfigError,
- CookiesError,
- FragmentError,
- HashFetchError,
- RequestError,
- TransactionError,
- UserNotFoundError,
- WalletError,
-)
-from app.core.logging import logger, setup_logging
-
-__all__ = [
- "ADS_PAGE",
- "BASE_HEADERS",
- "DEVICE",
- "PREMIUM_PAGE",
- "STARS_PAGE",
- "WALLET_CLASSES",
- "WalletVersion",
- "ConfigError",
- "CookiesError",
- "FragmentError",
- "HashFetchError",
- "RequestError",
- "TransactionError",
- "UserNotFoundError",
- "WalletError",
- "config",
- "load_cookies",
- "logger",
- "setup_logging",
-]
diff --git a/app/core/config.py b/app/core/config.py
deleted file mode 100644
index 52811ee..0000000
--- a/app/core/config.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import logging
-import os
-from pathlib import Path
-
-from dotenv import load_dotenv
-
-from app.core.constants import SUPPORTED_WALLET_VERSIONS, WalletVersion
-from app.core.exceptions import ConfigError
-
-logger = logging.getLogger(__name__)
-
-
-class Config:
- SEED: str
- API_KEY: str
- WALLET_VERSION: WalletVersion
-
- def __init__(self) -> None:
- # Load .env if present; env vars already in the process take precedence
- env_path = Path(__file__).resolve().parents[2] / ".env"
- if env_path.exists():
- load_dotenv(env_path)
-
- missing = [k for k in ("SEED", "API_KEY") if not os.getenv(k, "").strip()]
- if missing:
- raise ConfigError(
- f"Missing required environment variables: {', '.join(missing)}. "
- "Copy .env.example to .env and fill in SEED and API_KEY."
- )
-
- self.SEED = os.getenv("SEED", "").strip()
- self.API_KEY = os.getenv("API_KEY", "").strip()
-
- version = os.getenv("WALLET_VERSION", "V5R1").strip().upper()
- if version not in SUPPORTED_WALLET_VERSIONS:
- raise ConfigError(
- f"Unsupported WALLET_VERSION '{version}'. " f"Must be one of: {', '.join(sorted(SUPPORTED_WALLET_VERSIONS))}."
- )
- self.WALLET_VERSION: WalletVersion = version # type: ignore[assignment]
-
-
-config: Config | None = None
-try:
- config = Config()
-except ConfigError as e:
- logger.warning("Configuration not loaded: %s", e)
diff --git a/app/core/cookies.py b/app/core/cookies.py
deleted file mode 100644
index 4f43be0..0000000
--- a/app/core/cookies.py
+++ /dev/null
@@ -1,32 +0,0 @@
-import json
-import logging
-from pathlib import Path
-from typing import Any
-
-from app.core.exceptions import CookiesError
-
-logger = logging.getLogger(__name__)
-
-_REQUIRED_KEYS = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
-
-
-def load_cookies() -> dict[str, Any]:
- cookies_path = Path(__file__).resolve().parents[2] / "cookies.json"
-
- if not cookies_path.exists():
- raise CookiesError("cookies.json not found. Create it in the project root and paste your Fragment cookies.")
-
- try:
- with cookies_path.open("r", encoding="utf-8") as f:
- cookies = json.load(f)
- except Exception as exc:
- raise CookiesError(f"Failed to read cookies.json: {exc}") from exc
-
- missing = [k for k in _REQUIRED_KEYS if not str(cookies.get(k, "")).strip()]
- if missing:
- raise CookiesError(
- f"cookies.json is missing or has empty values for: {', '.join(missing)}. "
- "Open Fragment.com in your browser, copy fresh cookies, and update the file."
- )
-
- return cookies
diff --git a/app/core/exceptions.py b/app/core/exceptions.py
deleted file mode 100644
index 9a59b65..0000000
--- a/app/core/exceptions.py
+++ /dev/null
@@ -1,42 +0,0 @@
-__all__ = [
- "ConfigError",
- "CookiesError",
- "FragmentError",
- "HashFetchError",
- "RequestError",
- "TransactionError",
- "UserNotFoundError",
- "WalletError",
-]
-
-
-class FragmentError(Exception):
- """Base exception for all Fragment API errors."""
-
-
-class ConfigError(FragmentError):
- """Raised when .env is missing or required keys are absent."""
-
-
-class CookiesError(FragmentError):
- """Raised when cookies.json is missing, unreadable, or has empty required fields."""
-
-
-class HashFetchError(FragmentError):
- """Raised when the Fragment API hash cannot be fetched from the page."""
-
-
-class UserNotFoundError(FragmentError):
- """Raised when the target Telegram user is not found on Fragment."""
-
-
-class WalletError(FragmentError):
- """Raised for TON wallet issues (connection, balance, account info)."""
-
-
-class TransactionError(FragmentError):
- """Raised when a TON transaction fails to build or broadcast."""
-
-
-class RequestError(FragmentError):
- """Raised when a Fragment API response cannot be parsed."""
diff --git a/app/core/logging.py b/app/core/logging.py
deleted file mode 100644
index f701f52..0000000
--- a/app/core/logging.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import logging
-
-
-def setup_logging() -> None:
- formatter = logging.Formatter(fmt="[%(asctime)s] - %(levelname)s: %(message)s", datefmt="%d.%m.%y %H:%M:%S")
-
- console_handler = logging.StreamHandler()
- console_handler.setLevel(logging.INFO)
- console_handler.setFormatter(formatter)
-
- file_handler = logging.FileHandler("FragmentAPI.log", mode="w", encoding="utf-8")
- file_handler.setLevel(logging.DEBUG)
- file_handler.setFormatter(formatter)
- logging.basicConfig(level=logging.DEBUG, handlers=[console_handler, file_handler], force=True)
-
- logging.getLogger("httpx").setLevel(logging.WARNING)
- logging.getLogger("httpcore").setLevel(logging.WARNING)
-
-
-logger = logging.getLogger(__name__)
diff --git a/app/methods/__init__.py b/app/methods/__init__.py
deleted file mode 100644
index 590e0e7..0000000
--- a/app/methods/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from app.methods.premium import buy_premium
-from app.methods.stars import buy_stars
-from app.methods.ton import topup_ton
-
-__all__ = ["buy_premium", "buy_stars", "topup_ton"]
diff --git a/app/methods/premium.py b/app/methods/premium.py
deleted file mode 100644
index 798bc6e..0000000
--- a/app/methods/premium.py
+++ /dev/null
@@ -1,151 +0,0 @@
-import json
-import logging
-import time
-
-import httpx
-
-from app.core import (
- BASE_HEADERS,
- DEVICE,
- PREMIUM_PAGE,
- FragmentError,
- UserNotFoundError,
- load_cookies,
-)
-from app.utils import (
- execute_transaction_request,
- get_account_info,
- get_fragment_hash,
- parse_json_response,
- process_transaction,
-)
-
-logger = logging.getLogger(__name__)
-
-# Page-specific headers
-HEADERS: dict[str, str] = {
- **BASE_HEADERS,
- "referer": PREMIUM_PAGE,
- "x-aj-referer": PREMIUM_PAGE,
-}
-
-
-async def search_premium_recipient(
- client: httpx.AsyncClient,
- fragment_hash: str,
- username: str,
- months: int,
-) -> str:
- resp = await client.post(
- f"https://fragment.com/api?hash={fragment_hash}",
- headers=HEADERS,
- data={
- "query": username,
- "months": months,
- "method": "searchPremiumGiftRecipient",
- },
- )
- result = parse_json_response(resp, "searchPremiumGiftRecipient")
- recipient = result.get("found", {}).get("recipient")
- if not recipient:
- raise UserNotFoundError(
- f"Telegram user '{username}' was not found on Fragment. "
- "Make sure the username is correct and the account exists."
- )
- return recipient
-
-
-async def init_gift_premium(
- client: httpx.AsyncClient,
- fragment_hash: str,
- recipient: str,
- months: int,
-) -> str:
- await client.post(
- f"https://fragment.com/api?hash={fragment_hash}",
- headers=HEADERS,
- data={
- "mode": "new",
- "lv": "false",
- "dh": str(int(time.time())),
- "method": "updatePremiumState",
- },
- )
- resp = await client.post(
- f"https://fragment.com/api?hash={fragment_hash}",
- headers=HEADERS,
- data={
- "recipient": recipient,
- "months": months,
- "method": "initGiftPremiumRequest",
- },
- )
- result = parse_json_response(resp, "initGiftPremiumRequest")
- req_id = result.get("req_id")
- if not req_id:
- raise FragmentError(
- "Fragment did not return a request ID for this Premium purchase. "
- "The session may have expired — refresh your cookies."
- )
- return req_id
-
-
-async def buy_premium(username: str, months: int, show_sender: bool = True) -> dict:
- if months not in (3, 6, 12):
- return {
- "success": False,
- "error": "Invalid duration. Choose 3, 6, or 12 months.",
- }
-
- try:
- logger.info("Loading session cookies")
- cookies = load_cookies()
-
- logger.info("Fetching Fragment session hash")
- fragment_hash = await get_fragment_hash(cookies, HEADERS, PREMIUM_PAGE)
-
- # logger.info("Retrieving TON wallet info")
- account = await get_account_info()
-
- async with httpx.AsyncClient(cookies=cookies) as client:
- logger.info("Searching recipient: %s", username)
- recipient = await search_premium_recipient(client, fragment_hash, username, months)
-
- logger.info("Initializing Premium gift request: %s months to %s", months, username)
- req_id = await init_gift_premium(client, fragment_hash, recipient, months)
-
- # logger.info("Requesting transaction payload (req_id=%s)", req_id)
- tx_data = {
- "account": json.dumps(account),
- "device": DEVICE,
- "transaction": 1,
- "id": req_id,
- "show_sender": int(show_sender),
- "method": "getGiftPremiumLink",
- }
- transaction = await execute_transaction_request(client, HEADERS, account, tx_data, fragment_hash)
-
- logger.info("Broadcasting transaction to TON blockchain")
- tx_hash = await process_transaction(transaction)
- logger.info(
- "Premium purchase successful: %s months -> %s | tx: %s",
- months,
- username,
- tx_hash,
- )
- return {
- "success": True,
- "data": {
- "transaction_id": tx_hash,
- "username": username,
- "months": months,
- "timestamp": int(time.time()),
- },
- }
-
- except FragmentError as exc:
- logger.error("Premium purchase failed — %s", exc)
- return {"success": False, "error": str(exc)}
- except Exception as exc:
- logger.exception("Unexpected error during Premium purchase")
- return {"success": False, "error": f"Unexpected error: {exc}"}
diff --git a/app/methods/stars.py b/app/methods/stars.py
deleted file mode 100644
index af21079..0000000
--- a/app/methods/stars.py
+++ /dev/null
@@ -1,133 +0,0 @@
-import json
-import logging
-import time
-
-import httpx
-
-from app.core import (
- BASE_HEADERS,
- DEVICE,
- STARS_PAGE,
- FragmentError,
- UserNotFoundError,
- load_cookies,
-)
-from app.utils import (
- execute_transaction_request,
- get_account_info,
- get_fragment_hash,
- parse_json_response,
- process_transaction,
-)
-
-logger = logging.getLogger(__name__)
-
-# Page-specific headers
-HEADERS: dict[str, str] = {
- **BASE_HEADERS,
- "referer": STARS_PAGE,
- "x-aj-referer": STARS_PAGE,
-}
-
-
-async def search_stars_recipient(
- client: httpx.AsyncClient,
- fragment_hash: str,
- username: str,
-) -> str:
- resp = await client.post(
- f"https://fragment.com/api?hash={fragment_hash}",
- headers=HEADERS,
- data={"query": username, "quantity": "", "method": "searchStarsRecipient"},
- )
- result = parse_json_response(resp, "searchStarsRecipient")
- recipient = result.get("found", {}).get("recipient")
- if not recipient:
- raise UserNotFoundError(
- f"Telegram user '{username}' was not found on Fragment. "
- "Make sure the username is correct and the account exists."
- )
- return recipient
-
-
-async def init_buy_stars(
- client: httpx.AsyncClient,
- fragment_hash: str,
- recipient: str,
- amount: int,
-) -> str:
- resp = await client.post(
- f"https://fragment.com/api?hash={fragment_hash}",
- headers=HEADERS,
- data={
- "recipient": recipient,
- "quantity": amount,
- "method": "initBuyStarsRequest",
- },
- )
- result = parse_json_response(resp, "initBuyStarsRequest")
- req_id = result.get("req_id")
- if not req_id:
- raise FragmentError(
- "Fragment did not return a request ID for this Stars purchase. "
- "The session may have expired — refresh your cookies."
- )
- return req_id
-
-
-async def buy_stars(username: str, amount: int, show_sender: bool = True) -> dict:
- if not isinstance(amount, int) or amount < 50:
- return {"success": False, "error": "Amount must be an integer >= 50 stars."}
-
- try:
- logger.info("Loading session cookies")
- cookies = load_cookies()
-
- logger.info("Fetching Fragment session hash")
- fragment_hash = await get_fragment_hash(cookies, HEADERS, STARS_PAGE)
-
- # logger.info("Retrieving TON wallet info")
- account = await get_account_info()
-
- async with httpx.AsyncClient(cookies=cookies) as client:
- logger.info("Searching recipient: %s", username)
- recipient = await search_stars_recipient(client, fragment_hash, username)
-
- logger.info("Initializing Stars purchase request: %s stars to %s", amount, username)
- req_id = await init_buy_stars(client, fragment_hash, recipient, amount)
-
- # logger.info("Requesting transaction payload (req_id=%s)", req_id)
- tx_data = {
- "account": json.dumps(account),
- "device": DEVICE,
- "transaction": 1,
- "id": req_id,
- "show_sender": int(show_sender),
- "method": "getBuyStarsLink",
- }
- transaction = await execute_transaction_request(client, HEADERS, account, tx_data, fragment_hash)
-
- logger.info("Broadcasting transaction to TON blockchain")
- tx_hash = await process_transaction(transaction)
- logger.info(
- "Stars purchase successful: %s stars -> %s | tx: %s",
- amount,
- username,
- tx_hash,
- )
- return {
- "success": True,
- "data": {
- "transaction_id": tx_hash,
- "username": username,
- "amount": amount,
- "timestamp": int(time.time()),
- },
- }
-
- except FragmentError as exc:
- logger.error("Stars purchase failed — %s", exc)
- return {"success": False, "error": str(exc)}
- except Exception as exc:
- logger.exception("Unexpected error during Stars purchase")
- return {"success": False, "error": f"Unexpected error: {exc}"}
diff --git a/app/methods/ton.py b/app/methods/ton.py
deleted file mode 100644
index ee5d49f..0000000
--- a/app/methods/ton.py
+++ /dev/null
@@ -1,132 +0,0 @@
-import json
-import logging
-import time
-
-import httpx
-
-from app.core import (
- ADS_PAGE,
- BASE_HEADERS,
- DEVICE,
- FragmentError,
- UserNotFoundError,
- load_cookies,
-)
-from app.utils import (
- execute_transaction_request,
- get_account_info,
- get_fragment_hash,
- parse_json_response,
- process_transaction,
-)
-
-logger = logging.getLogger(__name__)
-
-# Page-specific headers
-HEADERS: dict[str, str] = {
- **BASE_HEADERS,
- "referer": ADS_PAGE,
- "x-aj-referer": ADS_PAGE,
-}
-
-
-async def search_ads_recipient(
- client: httpx.AsyncClient,
- fragment_hash: str,
- username: str,
-) -> str:
- await client.post(
- f"https://fragment.com/api?hash={fragment_hash}",
- headers=HEADERS,
- data={"mode": "new", "method": "updateAdsTopupState"},
- )
- resp = await client.post(
- f"https://fragment.com/api?hash={fragment_hash}",
- headers=HEADERS,
- data={"query": username, "method": "searchAdsTopupRecipient"},
- )
- result = parse_json_response(resp, "searchAdsTopupRecipient")
- recipient = result.get("found", {}).get("recipient")
- if not recipient:
- raise UserNotFoundError(
- f"Telegram user '{username}' was not found on Fragment. "
- "Make sure the username is correct and the account exists."
- )
- return recipient
-
-
-async def init_ads_topup(
- client: httpx.AsyncClient,
- fragment_hash: str,
- recipient: str,
- amount: int,
-) -> str:
- resp = await client.post(
- f"https://fragment.com/api?hash={fragment_hash}",
- headers=HEADERS,
- data={
- "recipient": recipient,
- "amount": amount,
- "method": "initAdsTopupRequest",
- },
- )
- result = parse_json_response(resp, "initAdsTopupRequest")
- req_id = result.get("req_id")
- if not req_id:
- raise FragmentError(
- "Fragment did not return a request ID for this TON topup. " "The session may have expired — refresh your cookies."
- )
- return req_id
-
-
-async def topup_ton(username: str, amount: int, show_sender: bool = True) -> dict:
- if not isinstance(amount, int) or amount < 1:
- return {"success": False, "error": "Amount must be an integer >= 1 TON."}
-
- try:
- logger.info("Loading session cookies")
- cookies = load_cookies()
-
- logger.info("Fetching Fragment session hash")
- fragment_hash = await get_fragment_hash(cookies, HEADERS, ADS_PAGE)
-
- # logger.info("Retrieving TON wallet info")
- account = await get_account_info()
-
- async with httpx.AsyncClient(cookies=cookies) as client:
- logger.info("Searching recipient: %s", username)
- recipient = await search_ads_recipient(client, fragment_hash, username)
-
- logger.info("Initializing topup request: %s TON to %s", amount, username)
- req_id = await init_ads_topup(client, fragment_hash, recipient, amount)
-
- # logger.info("Requesting transaction payload (req_id=%s)", req_id)
- tx_data = {
- "account": json.dumps(account),
- "device": DEVICE,
- "transaction": 1,
- "id": req_id,
- "show_sender": int(show_sender),
- "method": "getAdsTopupLink",
- }
- transaction = await execute_transaction_request(client, HEADERS, account, tx_data, fragment_hash)
-
- logger.info("Broadcasting transaction to TON blockchain")
- tx_hash = await process_transaction(transaction)
- logger.info("TON topup successful: %s TON -> %s | tx: %s", amount, username, tx_hash)
- return {
- "success": True,
- "data": {
- "transaction_id": tx_hash,
- "username": username,
- "amount": amount,
- "timestamp": int(time.time()),
- },
- }
-
- except FragmentError as exc:
- logger.error("TON topup failed — %s", exc)
- return {"success": False, "error": str(exc)}
- except Exception as exc:
- logger.exception("Unexpected error during TON topup")
- return {"success": False, "error": f"Unexpected error: {exc}"}
diff --git a/app/utils/__init__.py b/app/utils/__init__.py
deleted file mode 100644
index b2d51d5..0000000
--- a/app/utils/__init__.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from app.utils.client import execute_transaction_request, parse_json_response
-from app.utils.decoder import clean_decode
-from app.utils.hash import get_fragment_hash
-from app.utils.wallet import get_account_info, link_wallet, process_transaction
-
-__all__ = [
- "clean_decode",
- "execute_transaction_request",
- "get_account_info",
- "get_fragment_hash",
- "link_wallet",
- "parse_json_response",
- "process_transaction",
-]
diff --git a/app/utils/client.py b/app/utils/client.py
deleted file mode 100644
index 2ed4624..0000000
--- a/app/utils/client.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import logging
-from typing import Any
-
-import httpx
-
-from app.core import RequestError, WalletError
-from app.utils.wallet import link_wallet
-
-logger = logging.getLogger(__name__)
-
-
-def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
- try:
- return response.json()
- except Exception as exc:
- raise RequestError(f"Fragment API returned an unparseable response for '{context}': {exc}") from exc
-
-
-async def execute_transaction_request(
- client: httpx.AsyncClient,
- headers: dict,
- account: dict[str, Any],
- tx_data: dict[str, Any],
- fragment_hash: str,
-) -> dict[str, Any]:
- url = f"https://fragment.com/api?hash={fragment_hash}"
-
- resp = await client.post(url, headers=headers, data=tx_data)
- transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
-
- if transaction.get("need_verify"):
- if not await link_wallet(client, headers, account, fragment_hash):
- raise WalletError(
- "Failed to link your TON wallet to Fragment. " "Make sure the wallet matching your cookies is used."
- )
- resp = await client.post(url, headers=headers, data=tx_data)
- transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
-
- return transaction
diff --git a/app/utils/decoder.py b/app/utils/decoder.py
deleted file mode 100644
index 2d52926..0000000
--- a/app/utils/decoder.py
+++ /dev/null
@@ -1,36 +0,0 @@
-import base64
-import logging
-
-from pytoniq_core import Cell
-
-logger = logging.getLogger(__name__)
-
-
-# OLD decoder (manual base64 + regex, kept for reference):
-#
-# import re, string
-# def clean_decode(payload: str) -> str:
-# s = re.sub(r'[^A-Za-z0-9+/=]', '', payload.strip())
-# s += '=' * (-len(s) % 4)
-# text = base64.b64decode(s).decode('utf-8', errors='ignore')
-# text = ''.join(c for c in text if c in string.printable or c.isspace())
-# match = re.search(r'([0-9]*\s*Telegram .*?Ref#[A-Za-z0-9]+)', text, re.S)
-# return match.group(1).strip() if match else text.strip()
-
-
-def clean_decode(payload: str) -> str:
- # Pad and decode base64 → BOC bytes
- s = payload.strip()
- if not s:
- return ""
- s += "=" * (-len(s) % 4)
- boc = base64.b64decode(s)
-
- # Parse BOC cell and read snake-encoded text (skipping 32-bit op prefix)
- cell = Cell.one_from_boc(boc)
- sl = cell.begin_parse()
- sl.load_uint(32) # op code — always 0 for text comment
- result = sl.load_snake_string().strip()
-
- logger.debug("Payload: %s -> %s", payload, result.replace("\n", " "))
- return result
diff --git a/app/utils/hash.py b/app/utils/hash.py
deleted file mode 100644
index bf4f380..0000000
--- a/app/utils/hash.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import logging
-import re
-from typing import Any
-
-import httpx
-
-from app.core import HashFetchError
-
-logger = logging.getLogger(__name__)
-
-
-async def get_fragment_hash(
- cookies: dict[str, Any],
- headers: dict[str, str],
- page_url: str,
-) -> str:
- # Must look like a real browser navigation — not an XHR — otherwise Fragment
- # returns JSON (no hash in it) instead of full HTML.
- page_headers = {
- k: v
- for k, v in headers.items()
- if k
- not in (
- "accept",
- "accept-encoding",
- "content-type",
- "x-requested-with",
- "x-aj-referer",
- )
- }
- page_headers.update(
- {
- "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
- "referer": "https://fragment.com/",
- "sec-fetch-dest": "document",
- "sec-fetch-mode": "navigate",
- "upgrade-insecure-requests": "1",
- }
- )
-
- async with httpx.AsyncClient(cookies=cookies) as client:
- response = await client.get(page_url, headers=page_headers)
-
- if response.status_code != 200:
- raise HashFetchError(
- f"Fragment returned HTTP {response.status_code} for {page_url}. "
- "Check that your cookies are valid and not expired."
- )
-
- match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
- if not match:
- raise HashFetchError(
- f"Fragment hash not found in the page source of {page_url}. "
- "The page structure may have changed or you are not logged in."
- )
-
- return match.group(1)
diff --git a/app/utils/wallet.py b/app/utils/wallet.py
deleted file mode 100644
index 96ca4f4..0000000
--- a/app/utils/wallet.py
+++ /dev/null
@@ -1,107 +0,0 @@
-import base64
-import json
-import logging
-from typing import Any
-
-import httpx
-from tonutils.clients import TonapiClient
-from tonutils.types import NetworkGlobalID
-
-from app.core import DEVICE, WALLET_CLASSES, TransactionError, WalletError, config
-from app.utils.decoder import clean_decode
-
-logger = logging.getLogger(__name__)
-
-
-def initialize_ton_client() -> TonapiClient:
- return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY)
-
-
-async def process_transaction(transaction_data: dict) -> str:
- logger.debug("transaction_data: %s", transaction_data)
-
- if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
- raise TransactionError(
- "Fragment returned an invalid transaction payload. "
- "The API response is missing expected 'transaction.messages' data."
- )
-
- # 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,
- # causing the wallet contract to reject the new message.
- async with initialize_ton_client() as client:
- wallet_cls = WALLET_CLASSES[config.WALLET_VERSION]
- wallet, _, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED)
-
- # Check balance before broadcasting
- try:
- await wallet.refresh()
- balance_ton = wallet.balance / 1_000_000_000
- if balance_ton < 0.056:
- raise WalletError(f"TON wallet balance is too low: {balance_ton:.2f} TON. " "Minimum required is 0.056 TON.")
- except WalletError:
- raise
- except Exception as exc:
- raise WalletError(f"Wallet balance check failed: {exc}") from exc
-
- try:
- message = transaction_data["transaction"]["messages"][0]
- payload = clean_decode(message["payload"])
-
- result = await wallet.transfer(
- destination=message["address"],
- amount=int(message["amount"]), # nanotons, not TON
- body=payload,
- )
- tx_hash = result.normalized_hash
- return tx_hash
- except (WalletError, TransactionError):
- raise
- except Exception as exc:
- raise TransactionError(f"Transaction broadcast failed: {exc}") from exc
-
-
-async def get_account_info() -> dict[str, Any]:
- async with initialize_ton_client() as client:
- try:
- wallet_cls = WALLET_CLASSES[config.WALLET_VERSION]
- wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED)
- boc = wallet.state_init.serialize().to_boc()
- return {
- "address": wallet.address.to_str(False, False),
- "publicKey": pub_key.as_hex,
- "chain": "-239",
- "walletStateInit": base64.b64encode(boc).decode(),
- }
- except Exception as exc:
- raise WalletError(f"Failed to retrieve wallet account info: {exc}") from exc
-
-
-async def link_wallet(
- client: httpx.AsyncClient,
- headers: dict,
- account: dict[str, Any],
- fragment_hash: str,
-) -> bool:
- resp = await client.post(
- f"https://fragment.com/api?hash={fragment_hash}",
- headers=headers,
- data={
- "account": json.dumps(account),
- "device": DEVICE,
- "method": "linkWallet",
- },
- )
- result = resp.json()
-
- if result.get("ok"):
- return True
-
- if "transaction" in result:
- try:
- await process_transaction(result)
- return True
- except (TransactionError, WalletError):
- return False
-
- return False
diff --git a/cookies.example.json b/cookies.example.json
deleted file mode 100644
index 7e8b0d5..0000000
--- a/cookies.example.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "stel_ssid": "",
- "stel_dt": "",
- "stel_token": "",
- "stel_ton_token": ""
-}
diff --git a/examples/get_wallet.py b/examples/get_wallet.py
new file mode 100644
index 0000000..936636e
--- /dev/null
+++ b/examples/get_wallet.py
@@ -0,0 +1,40 @@
+"""
+Example: initializing FragmentClient.
+
+Cookies can be passed as a dict or as a JSON string.
+wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
+"""
+
+import asyncio
+
+from pyfragment import FragmentClient
+
+SEED = "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13 word14 word15 word16 word17 word18 word19 word20 word21 word22 word23 word24"
+API_KEY = "YOUR_TONAPI_KEY"
+COOKIES = {
+ "stel_ssid": "YOUR_STEL_SSID",
+ "stel_dt": "YOUR_STEL_DT",
+ "stel_token": "YOUR_STEL_TOKEN",
+ "stel_ton_token": "YOUR_STEL_TON_TOKEN",
+}
+
+
+async def main() -> None:
+ client = FragmentClient(
+ seed=SEED,
+ api_key=API_KEY,
+ cookies=COOKIES,
+ wallet_version="V5R1", # or "V4R2"
+ )
+
+ wallet = await client.get_wallet()
+
+ print("FragmentClient initialized")
+ print(" %-16s %s" % ("Wallet version:", client.wallet_version))
+ print(" %-16s %s" % ("Address:", wallet.address))
+ print(" %-16s %s" % ("State:", wallet.state))
+ print(" %-16s %s TON" % ("Balance:", wallet.balance))
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/purchase_premium.py b/examples/purchase_premium.py
new file mode 100644
index 0000000..d4366f2
--- /dev/null
+++ b/examples/purchase_premium.py
@@ -0,0 +1,45 @@
+"""
+Example: purchase Telegram Premium for a user.
+
+Supported durations: 3, 6, or 12 months.
+Set show_sender=False to send anonymously.
+"""
+
+import asyncio
+
+from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
+
+SEED = "word1 word2 ... word24"
+API_KEY = "YOUR_TONAPI_KEY"
+COOKIES = {
+ "stel_ssid": "YOUR_STEL_SSID",
+ "stel_dt": "YOUR_STEL_DT",
+ "stel_token": "YOUR_STEL_TOKEN",
+ "stel_ton_token": "YOUR_STEL_TON_TOKEN",
+}
+
+USERNAME = "@username"
+MONTHS = 3 # 3, 6, or 12
+
+
+async def main() -> None:
+ client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES)
+
+ try:
+ result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True)
+ except UserNotFoundError:
+ print(f"User {USERNAME!r} not found on Fragment.")
+ return
+ except ConfigurationError as e:
+ print(f"Invalid parameters: {e}")
+ return
+
+ print("Premium purchased")
+ print(" %-14s %s" % ("Username:", result.username))
+ print(" %-14s %s months" % ("Duration:", result.months))
+ print(" %-14s %s" % ("Transaction:", result.transaction_id))
+ print(" %-14s %s" % ("Timestamp:", result.timestamp))
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/purchase_stars.py b/examples/purchase_stars.py
new file mode 100644
index 0000000..c0902a4
--- /dev/null
+++ b/examples/purchase_stars.py
@@ -0,0 +1,45 @@
+"""
+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.
+"""
+
+import asyncio
+
+from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
+
+SEED = "word1 word2 ... word24"
+API_KEY = "YOUR_TONAPI_KEY"
+COOKIES = {
+ "stel_ssid": "YOUR_STEL_SSID",
+ "stel_dt": "YOUR_STEL_DT",
+ "stel_token": "YOUR_STEL_TOKEN",
+ "stel_ton_token": "YOUR_STEL_TON_TOKEN",
+}
+
+USERNAME = "@username"
+AMOUNT = 500 # 50–1 000 000
+
+
+async def main() -> None:
+ client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES)
+
+ try:
+ result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True)
+ except UserNotFoundError:
+ print(f"User {USERNAME!r} not found on Fragment.")
+ return
+ except ConfigurationError as e:
+ print(f"Invalid parameters: {e}")
+ return
+
+ print("Stars purchased")
+ print(" %-14s %s" % ("Username:", result.username))
+ print(" %-14s %s" % ("Stars:", result.stars))
+ print(" %-14s %s" % ("Transaction:", result.transaction_id))
+ print(" %-14s %s" % ("Timestamp:", result.timestamp))
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/topup_ton.py b/examples/topup_ton.py
new file mode 100644
index 0000000..f362256
--- /dev/null
+++ b/examples/topup_ton.py
@@ -0,0 +1,48 @@
+"""
+Example: top up a 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 topup amount + ~0.056 TON for gas.
+"""
+
+import asyncio
+
+from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError, WalletError
+
+SEED = "word1 word2 ... word24"
+API_KEY = "YOUR_TONAPI_KEY"
+COOKIES = {
+ "stel_ssid": "YOUR_STEL_SSID",
+ "stel_dt": "YOUR_STEL_DT",
+ "stel_token": "YOUR_STEL_TOKEN",
+ "stel_ton_token": "YOUR_STEL_TON_TOKEN",
+}
+
+USERNAME = "@username"
+AMOUNT = 10 # TON, integer — 1–1 000 000 000
+
+
+async def main() -> None:
+ client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES)
+
+ try:
+ result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True)
+ except UserNotFoundError:
+ print(f"User {USERNAME!r} not found on Fragment.")
+ return
+ except WalletError as e:
+ print(f"Wallet error: {e}")
+ return
+ except ConfigurationError as e:
+ print(f"Invalid parameters: {e}")
+ return
+
+ print("TON topped up")
+ print(" %-14s %s" % ("Username:", result.username))
+ print(" %-14s %s TON" % ("Amount:", result.amount))
+ print(" %-14s %s" % ("Transaction:", result.transaction_id))
+ print(" %-14s %s" % ("Timestamp:", result.timestamp))
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/main.py b/main.py
deleted file mode 100644
index 4c85964..0000000
--- a/main.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import asyncio
-import logging
-
-from app.core import setup_logging
-from app.methods import buy_premium, buy_stars, topup_ton
-
-logger = logging.getLogger(__name__)
-
-
-async def topup_ton_example():
- logger.info("Starting TON topup example")
-
- # @bohd4nx - target username, 100 - TON amount (integer 1-1000000000 (one billion))
- # show_sender=True — recipient sees who sent the topup
- result = await topup_ton("@bohd4nx", 100, show_sender=True)
-
- if result["success"]:
- pass # Transaction successful, details are logged in the method
- else:
- logger.error(f"TON topup failed: {result['error']}")
-
-
-async def buy_premium_example():
- logger.info("Starting Premium purchase example")
-
- # @bohd4nx - target username, 12 - months duration (3, 6, or 12 only)
- # show_sender=True — recipient sees who gifted the Premium
- result = await buy_premium("@bohd4nx", 12, show_sender=True)
-
- if result["success"]:
- pass # Transaction successful, details are logged in the method
- else:
- logger.error(f"Premium purchase failed: {result['error']}")
-
-
-async def buy_stars_example():
- logger.info("Starting Stars purchase example")
-
- # @bohd4nx - target username, 1000000 - stars amount (integer 50-1000000 (one million))
- # show_sender=True — recipient sees who sent the Stars
- result = await buy_stars("@bohd4nx", 1000000, show_sender=True)
-
- if result["success"]:
- pass # Transaction successful, details are logged in the method
- else:
- logger.error(f"Stars purchase failed: {result['error']}")
-
-
-async def main():
- setup_logging()
- logger.info("Starting Fragment API by @bohd4nx - examples")
-
- await topup_ton_example()
- await buy_premium_example()
- await buy_stars_example()
-
- logger.info("All examples completed")
-
-
-if __name__ == "__main__":
- logger.info("Fragment API by @bohd4nx - Usage Examples")
- logger.info("Supported username formats: @username, username")
- logger.info("Limits: TON minimum 1, Premium 3/6/12 months, Stars minimum 50")
- logger.info("Setup: Copy .env.example to .env and fill all fields")
-
- asyncio.run(main())
diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py
new file mode 100644
index 0000000..d08f26e
--- /dev/null
+++ b/pyfragment/__init__.py
@@ -0,0 +1,51 @@
+# Copyright (c) 2026 bohd4nx
+#
+# This source code is licensed under the MIT License found in the
+# LICENSE file in the root directory of this source tree.
+
+from importlib.metadata import version
+
+from pyfragment.client import FragmentClient
+from pyfragment.types import (
+ AdsTopupResult,
+ ClientError,
+ ConfigurationError,
+ CookieError,
+ FragmentAPIError,
+ FragmentError,
+ FragmentPageError,
+ OperationError,
+ ParseError,
+ PremiumResult,
+ StarsResult,
+ TransactionError,
+ UnexpectedError,
+ UserNotFoundError,
+ VerificationError,
+ WalletError,
+ WalletInfo,
+)
+
+__version__: str = version("pyfragment")
+
+__all__ = [
+ "__version__",
+ "FragmentClient",
+ "AdsTopupResult",
+ "PremiumResult",
+ "StarsResult",
+ "WalletInfo",
+ "ClientError",
+ "ConfigurationError",
+ "CookieError",
+ "FragmentAPIError",
+ "FragmentError",
+ "FragmentPageError",
+ "OperationError",
+ "ParseError",
+ "TransactionError",
+ "UnexpectedError",
+ "UserNotFoundError",
+ "VerificationError",
+ "WalletError",
+]
diff --git a/pyfragment/client.py b/pyfragment/client.py
new file mode 100644
index 0000000..09a62df
--- /dev/null
+++ b/pyfragment/client.py
@@ -0,0 +1,146 @@
+import json
+
+from pyfragment.methods.premium import purchase_premium
+from pyfragment.methods.stars import purchase_stars
+from pyfragment.methods.ton import topup_ton
+from pyfragment.types import (
+ REQUIRED_COOKIE_KEYS,
+ SUPPORTED_WALLET_VERSIONS,
+ AdsTopupResult,
+ ConfigurationError,
+ CookieError,
+ PremiumResult,
+ StarsResult,
+ WalletInfo,
+ WalletVersion,
+)
+from pyfragment.utils.wallet import get_wallet_info
+
+
+class FragmentClient:
+ """
+ Client for the Fragment.com API.
+
+ .. note::
+ This library is not affiliated with, endorsed by, or in any way officially
+ connected with Fragment or Telegram.
+
+ Args:
+ seed: 24-word mnemonic phrase for the TON wallet.
+ api_key: Tonapi API key — get one at https://tonconsole.com.
+ cookies: Fragment session cookies as a dict or JSON string.
+ wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
+
+ Raises:
+ ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid.
+ CookieError: If ``cookies`` cannot be parsed or are missing required keys.
+
+ Example::
+
+ async with FragmentClient(
+ seed="word1 word2 ...",
+ api_key="AAABBB...",
+ cookies={"stel_ssid": "...", "stel_dt": "...", ...},
+ ) as client:
+ print(await client.get_wallet())
+ result = await client.purchase_premium("@username", months=6)
+ print(result.transaction_id)
+ """
+
+ def __init__(
+ self,
+ seed: str,
+ api_key: str,
+ cookies: dict | str,
+ wallet_version: str = "V5R1",
+ ) -> None:
+ missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
+ if 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 len(api_key.strip()) < 68:
+ raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip())))
+
+ if isinstance(cookies, str):
+ try:
+ cookies = json.loads(cookies)
+ except Exception as exc:
+ raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
+
+ missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()]
+ if missing_keys:
+ raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
+
+ version = wallet_version.strip().upper()
+ if version not in SUPPORTED_WALLET_VERSIONS:
+ raise ConfigurationError(
+ ConfigurationError.UNSUPPORTED_VERSION.format(
+ version=version, supported=", ".join(sorted(SUPPORTED_WALLET_VERSIONS))
+ )
+ )
+
+ self.seed: str = seed.strip()
+ self.api_key: str = api_key.strip()
+ self.cookies: dict = cookies
+ self.wallet_version: WalletVersion = version # type: ignore[assignment]
+
+ async def __aenter__(self) -> "FragmentClient":
+ return self
+
+ async def __aexit__(self, *_: object) -> None:
+ pass
+
+ 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:
+ """Purchase Telegram Premium for a user.
+
+ Args:
+ username: Recipient's Telegram username (with or without ``@``).
+ months: Duration — ``3``, ``6``, or ``12``.
+ show_sender: Show your name as the sender. Defaults to ``True``.
+
+ Returns:
+ :class:`PremiumResult` with ``transaction_id``, ``username``, ``months``, ``timestamp``.
+ """
+ return await purchase_premium(self, username, months, show_sender)
+
+ async def purchase_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult:
+ """Purchase Telegram Stars for a user.
+
+ Args:
+ username: Recipient's Telegram username (with or without ``@``).
+ amount: Number of stars — integer from ``50`` to ``1 000 000``.
+ show_sender: Show your name as the gift sender. Defaults to ``True``.
+
+ Returns:
+ :class:`StarsResult` with ``transaction_id``, ``username``, ``stars``, ``timestamp``.
+ """
+ return await purchase_stars(self, username, amount, show_sender)
+
+ async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
+ """Top up Telegram Ads balance with TON.
+
+ Args:
+ username: Ads account username (with or without ``@``).
+ amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
+ show_sender: Show your name as the sender. Defaults to ``True``.
+
+ Returns:
+ :class:`AdsTopupResult` with ``transaction_id``, ``username``, ``amount``, ``timestamp``.
+ """
+ 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)
diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py
new file mode 100644
index 0000000..c0f4e2e
--- /dev/null
+++ b/pyfragment/methods/__init__.py
@@ -0,0 +1,5 @@
+from pyfragment.methods.premium import purchase_premium
+from pyfragment.methods.stars import purchase_stars
+from pyfragment.methods.ton import topup_ton
+
+__all__ = ["purchase_premium", "purchase_stars", "topup_ton"]
diff --git a/pyfragment/methods/premium.py b/pyfragment/methods/premium.py
new file mode 100644
index 0000000..0c19c6b
--- /dev/null
+++ b/pyfragment/methods/premium.py
@@ -0,0 +1,120 @@
+import json
+import time
+from typing import TYPE_CHECKING
+
+import httpx
+
+from pyfragment.types import (
+ BASE_HEADERS,
+ DEVICE,
+ PREMIUM_PAGE,
+ ConfigurationError,
+ FragmentAPIError,
+ FragmentError,
+ PremiumResult,
+ UnexpectedError,
+ UserNotFoundError,
+)
+from pyfragment.utils import (
+ execute_transaction_request,
+ fragment_post,
+ get_account_info,
+ get_fragment_hash,
+ process_transaction,
+)
+
+if TYPE_CHECKING:
+ from pyfragment.client import FragmentClient
+
+# Page-specific headers
+HEADERS: dict[str, str] = {
+ **BASE_HEADERS,
+ "referer": PREMIUM_PAGE,
+ "x-aj-referer": PREMIUM_PAGE,
+}
+
+
+async def _search_recipient(
+ session: httpx.AsyncClient,
+ fragment_hash: str,
+ username: str,
+ months: int,
+) -> str:
+ result = await fragment_post(
+ session,
+ fragment_hash,
+ HEADERS,
+ {
+ "query": username,
+ "months": months,
+ "method": "searchPremiumGiftRecipient",
+ },
+ )
+ recipient = result.get("found", {}).get("recipient")
+ if not recipient:
+ raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
+ return recipient
+
+
+async def _init_request(
+ session: httpx.AsyncClient,
+ fragment_hash: str,
+ recipient: str,
+ months: int,
+) -> str:
+ await fragment_post(
+ session,
+ fragment_hash,
+ HEADERS,
+ {
+ "mode": "new",
+ "lv": "false",
+ "dh": str(int(time.time())),
+ "method": "updatePremiumState",
+ },
+ )
+ result = await fragment_post(
+ session,
+ fragment_hash,
+ HEADERS,
+ {
+ "recipient": recipient,
+ "months": months,
+ "method": "initGiftPremiumRequest",
+ },
+ )
+ req_id = result.get("req_id")
+ if not req_id:
+ raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
+ return req_id
+
+
+async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult:
+ if months not in (3, 6, 12):
+ raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
+
+ try:
+ fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE)
+ account = await get_account_info(client)
+
+ async with httpx.AsyncClient(cookies=client.cookies) as session:
+ recipient = await _search_recipient(session, fragment_hash, username, months)
+ req_id = await _init_request(session, fragment_hash, recipient, months)
+
+ tx_data = {
+ "account": json.dumps(account),
+ "device": DEVICE,
+ "transaction": 1,
+ "id": req_id,
+ "show_sender": int(show_sender),
+ "method": "getGiftPremiumLink",
+ }
+ transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
+
+ tx_hash = await process_transaction(client, transaction)
+ return PremiumResult(transaction_id=tx_hash, username=username, months=months)
+
+ except FragmentError:
+ raise
+ except Exception as exc:
+ raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
diff --git a/pyfragment/methods/stars.py b/pyfragment/methods/stars.py
new file mode 100644
index 0000000..bc58fa8
--- /dev/null
+++ b/pyfragment/methods/stars.py
@@ -0,0 +1,107 @@
+import json
+from typing import TYPE_CHECKING
+
+import httpx
+
+from pyfragment.types import (
+ BASE_HEADERS,
+ DEVICE,
+ STARS_PAGE,
+ ConfigurationError,
+ FragmentAPIError,
+ FragmentError,
+ StarsResult,
+ UnexpectedError,
+ UserNotFoundError,
+)
+from pyfragment.utils import (
+ execute_transaction_request,
+ fragment_post,
+ get_account_info,
+ get_fragment_hash,
+ process_transaction,
+)
+
+if TYPE_CHECKING:
+ from pyfragment.client import FragmentClient
+
+# Page-specific headers
+HEADERS: dict[str, str] = {
+ **BASE_HEADERS,
+ "referer": STARS_PAGE,
+ "x-aj-referer": STARS_PAGE,
+}
+
+
+async def _search_recipient(
+ session: httpx.AsyncClient,
+ fragment_hash: str,
+ username: str,
+) -> str:
+ result = await fragment_post(
+ session,
+ fragment_hash,
+ HEADERS,
+ {
+ "query": username,
+ "quantity": "",
+ "method": "searchStarsRecipient",
+ },
+ )
+ recipient = result.get("found", {}).get("recipient")
+ if not recipient:
+ raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
+ return recipient
+
+
+async def _init_request(
+ session: httpx.AsyncClient,
+ fragment_hash: str,
+ recipient: str,
+ amount: int,
+) -> str:
+ result = await fragment_post(
+ session,
+ fragment_hash,
+ HEADERS,
+ {
+ "recipient": recipient,
+ "quantity": amount,
+ "method": "initBuyStarsRequest",
+ },
+ )
+ req_id = result.get("req_id")
+ if not req_id:
+ raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
+ return req_id
+
+
+async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult:
+ if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
+ raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
+
+ try:
+ fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE)
+ account = await get_account_info(client)
+
+ async with httpx.AsyncClient(cookies=client.cookies) as session:
+ recipient = await _search_recipient(session, fragment_hash, username)
+ req_id = await _init_request(session, fragment_hash, recipient, amount)
+
+ tx_data = {
+ "account": json.dumps(account),
+ "device": DEVICE,
+ "transaction": 1,
+ "id": req_id,
+ "show_sender": int(show_sender),
+ "method": "getBuyStarsLink",
+ }
+ transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
+
+ tx_hash = await process_transaction(client, transaction)
+ return StarsResult(transaction_id=tx_hash, username=username, stars=amount)
+
+ except FragmentError:
+ raise
+ except Exception as exc:
+ raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
diff --git a/pyfragment/methods/ton.py b/pyfragment/methods/ton.py
new file mode 100644
index 0000000..ef5871b
--- /dev/null
+++ b/pyfragment/methods/ton.py
@@ -0,0 +1,107 @@
+import json
+from typing import TYPE_CHECKING
+
+import httpx
+
+from pyfragment.types import (
+ BASE_HEADERS,
+ DEVICE,
+ TON_PAGE,
+ AdsTopupResult,
+ ConfigurationError,
+ FragmentAPIError,
+ FragmentError,
+ UnexpectedError,
+ UserNotFoundError,
+)
+from pyfragment.utils import (
+ execute_transaction_request,
+ fragment_post,
+ get_account_info,
+ get_fragment_hash,
+ process_transaction,
+)
+
+if TYPE_CHECKING:
+ from pyfragment.client import FragmentClient
+
+# Page-specific headers
+HEADERS: dict[str, str] = {
+ **BASE_HEADERS,
+ "referer": TON_PAGE,
+ "x-aj-referer": TON_PAGE,
+}
+
+
+async def _search_recipient(
+ session: httpx.AsyncClient,
+ fragment_hash: str,
+ username: str,
+) -> str:
+ await fragment_post(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"})
+ result = await fragment_post(
+ session,
+ fragment_hash,
+ HEADERS,
+ {
+ "query": username,
+ "method": "searchAdsTopupRecipient",
+ },
+ )
+ recipient = result.get("found", {}).get("recipient")
+ if not recipient:
+ raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
+ return recipient
+
+
+async def _init_request(
+ session: httpx.AsyncClient,
+ fragment_hash: str,
+ recipient: str,
+ amount: int,
+) -> str:
+ result = await fragment_post(
+ session,
+ fragment_hash,
+ HEADERS,
+ {
+ "recipient": recipient,
+ "amount": amount,
+ "method": "initAdsTopupRequest",
+ },
+ )
+ req_id = result.get("req_id")
+ if not req_id:
+ raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
+ return req_id
+
+
+async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
+ if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
+ raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
+
+ try:
+ fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE)
+ account = await get_account_info(client)
+
+ async with httpx.AsyncClient(cookies=client.cookies) as session:
+ recipient = await _search_recipient(session, fragment_hash, username)
+ req_id = await _init_request(session, fragment_hash, recipient, amount)
+
+ tx_data = {
+ "account": json.dumps(account),
+ "device": DEVICE,
+ "transaction": 1,
+ "id": req_id,
+ "show_sender": int(show_sender),
+ "method": "getAdsTopupLink",
+ }
+ transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
+
+ tx_hash = await process_transaction(client, transaction)
+ return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
+
+ except FragmentError:
+ raise
+ except Exception as exc:
+ raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
diff --git a/pyfragment/py.typed b/pyfragment/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py
new file mode 100644
index 0000000..c254997
--- /dev/null
+++ b/pyfragment/types/__init__.py
@@ -0,0 +1,62 @@
+from pyfragment.types.constants import (
+ BASE_HEADERS,
+ DEVICE,
+ MIN_TON_BALANCE,
+ PREMIUM_PAGE,
+ REQUIRED_COOKIE_KEYS,
+ STARS_PAGE,
+ SUPPORTED_WALLET_VERSIONS,
+ TON_PAGE,
+ WALLET_CLASSES,
+ WalletVersion,
+)
+from pyfragment.types.exceptions import (
+ ClientError,
+ ConfigurationError,
+ CookieError,
+ FragmentAPIError,
+ FragmentError,
+ FragmentPageError,
+ OperationError,
+ ParseError,
+ TransactionError,
+ UnexpectedError,
+ UserNotFoundError,
+ VerificationError,
+ WalletError,
+)
+from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult, WalletInfo
+
+__all__ = [
+ # constants
+ "BASE_HEADERS",
+ "DEVICE",
+ "MIN_TON_BALANCE",
+ "PREMIUM_PAGE",
+ "REQUIRED_COOKIE_KEYS",
+ "STARS_PAGE",
+ "SUPPORTED_WALLET_VERSIONS",
+ "TON_PAGE",
+ "WALLET_CLASSES",
+ "WalletVersion",
+ # client exceptions
+ "ClientError",
+ "ConfigurationError",
+ "CookieError",
+ # fragment exceptions
+ "FragmentAPIError",
+ "FragmentError",
+ "FragmentPageError",
+ "OperationError",
+ "ParseError",
+ "TransactionError",
+ "UnexpectedError",
+ "UserNotFoundError",
+ "VerificationError",
+ "WalletError",
+ # result types
+ "AdsTopupResult",
+ "PremiumResult",
+ "StarsResult",
+ "WalletInfo",
+]
diff --git a/app/core/constants.py b/pyfragment/types/constants.py
similarity index 86%
rename from app/core/constants.py
rename to pyfragment/types/constants.py
index 243edb5..2bb799d 100644
--- a/app/core/constants.py
+++ b/pyfragment/types/constants.py
@@ -10,10 +10,16 @@ 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, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
+# Minimum wallet balance required to cover TON network gas fees.
+MIN_TON_BALANCE: float = 0.056
+
+# Required Fragment session cookie keys
+REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
+
# Fragment page URLs
STARS_PAGE: str = "https://fragment.com/stars/buy"
PREMIUM_PAGE: str = "https://fragment.com/premium/gift"
-ADS_PAGE: str = "https://fragment.com/ads/topup"
+TON_PAGE: str = "https://fragment.com/ads/topup"
# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
DEVICE: str = json.dumps(
diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py
new file mode 100644
index 0000000..435a50c
--- /dev/null
+++ b/pyfragment/types/exceptions.py
@@ -0,0 +1,109 @@
+class FragmentError(Exception):
+ """Base exception for all pyfragment library errors."""
+
+
+class ClientError(FragmentError):
+ """Raised for client configuration and setup issues (bad params, invalid cookies)."""
+
+
+class ConfigurationError(ClientError):
+ """Raised when required client parameters are missing or invalid."""
+
+ MISSING_VARS = "Missing required parameter(s): {keys}."
+ UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
+ INVALID_MNEMONIC = "Invalid mnemonic: got {count} words, expected 12, 18, or 24."
+ INVALID_API_KEY = "Invalid Tonapi key: got {length} characters, expected at least 68. Get one at https://tonconsole.com."
+ 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_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON."
+
+
+class CookieError(ClientError):
+ """Raised when cookies are unreadable or missing required fields."""
+
+ READ_FAILED = "Failed to parse cookies: {exc}"
+ MISSING_KEYS = (
+ "Cookies are missing or have empty values for: {keys}. " "Open Fragment.com in your browser and copy fresh cookies."
+ )
+
+
+class FragmentAPIError(FragmentError):
+ """Raised for errors returned by Fragment's API responses."""
+
+ NO_REQUEST_ID = (
+ "Fragment did not return a request ID for '{context}'. " "The session may have expired — refresh your cookies."
+ )
+
+
+class FragmentPageError(FragmentAPIError):
+ """Raised when the Fragment page cannot be fetched or the API hash is not found."""
+
+ BAD_STATUS = "Fragment returned HTTP {status} for {url}. " "Check that your cookies are valid and not expired."
+ NOT_FOUND = (
+ "Fragment hash not found in the page source of {url}. " "The page structure may have changed or you are not logged in."
+ )
+
+
+class UserNotFoundError(FragmentAPIError):
+ """Raised when the target Telegram user is not found on Fragment."""
+
+ NOT_FOUND = (
+ "Telegram user '{username}' was not found on Fragment. " "Make sure the username is correct and the account exists."
+ )
+
+
+class TransactionError(FragmentAPIError):
+ """Raised when a TON transaction fails to build or broadcast."""
+
+ INVALID_PAYLOAD = (
+ "Fragment returned an invalid transaction payload. " "The API response is missing expected 'transaction.messages' data."
+ )
+ BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
+
+
+class ParseError(FragmentAPIError):
+ """Raised when a Fragment API response or payload cannot be parsed."""
+
+ UNPARSEABLE = "Fragment API returned an unparseable response for '{context}': {exc}"
+
+
+class VerificationError(FragmentAPIError):
+ """Raised when Fragment requires KYC verification before proceeding."""
+
+ KYC_REQUIRED = "Fragment requires identity (KYC) verification. " "Complete it at https://fragment.com/my/profile and retry."
+
+
+class OperationError(FragmentError):
+ """Raised for runtime operation failures unrelated to Fragment's API."""
+
+
+class WalletError(OperationError):
+ """Raised for TON wallet issues (connection, balance, account info)."""
+
+ LOW_BALANCE = "TON wallet balance is too low: {balance:.4f} TON available, {required:.4f} TON required."
+ BALANCE_CHECK_FAILED = "Wallet balance check failed: {exc}"
+ ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info: {exc}"
+ WALLET_INFO_FAILED = "Failed to retrieve wallet info: {exc}"
+
+
+class UnexpectedError(OperationError):
+ """Raised when an unexpected error occurs during an API call."""
+
+ UNEXPECTED = "An unexpected error occurred: {exc}"
+
+
+__all__ = [
+ "FragmentError",
+ "ClientError",
+ "ConfigurationError",
+ "CookieError",
+ "FragmentAPIError",
+ "FragmentPageError",
+ "UserNotFoundError",
+ "TransactionError",
+ "ParseError",
+ "VerificationError",
+ "OperationError",
+ "WalletError",
+ "UnexpectedError",
+]
diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py
new file mode 100644
index 0000000..a6ad138
--- /dev/null
+++ b/pyfragment/types/results.py
@@ -0,0 +1,55 @@
+import time
+from dataclasses import dataclass, field
+
+__all__ = ["AdsTopupResult", "PremiumResult", "StarsResult", "WalletInfo"]
+
+
+@dataclass
+class WalletInfo:
+ """Wallet state returned by :meth:`FragmentClient.get_wallet`."""
+
+ address: str
+ state: str
+ balance: float
+
+ def __repr__(self) -> str:
+ return f"WalletInfo(address='{self.address}', state='{self.state}', balance={self.balance} TON)"
+
+
+@dataclass
+class PremiumResult:
+ """Result of a successful Telegram Premium gift."""
+
+ transaction_id: str
+ username: str
+ months: int
+ timestamp: int = field(default_factory=lambda: int(time.time()))
+
+ def __repr__(self) -> str:
+ return f"PremiumResult(username='{self.username}', months={self.months}, tx='{self.transaction_id}')"
+
+
+@dataclass
+class StarsResult:
+ """Result of a successful Telegram Stars purchase."""
+
+ transaction_id: str
+ username: str
+ stars: int
+ timestamp: int = field(default_factory=lambda: int(time.time()))
+
+ def __repr__(self) -> str:
+ return f"StarsResult(username='{self.username}', stars={self.stars}, tx='{self.transaction_id}')"
+
+
+@dataclass
+class AdsTopupResult:
+ """Result of a successful Telegram Ads balance top-up."""
+
+ transaction_id: str
+ username: str
+ amount: int
+ timestamp: int = field(default_factory=lambda: int(time.time()))
+
+ def __repr__(self) -> str:
+ return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')"
diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py
new file mode 100644
index 0000000..59d3693
--- /dev/null
+++ b/pyfragment/utils/__init__.py
@@ -0,0 +1,18 @@
+from pyfragment.utils.decoder import clean_decode
+from pyfragment.utils.http import (
+ execute_transaction_request,
+ fragment_post,
+ get_fragment_hash,
+ parse_json_response,
+)
+from pyfragment.utils.wallet import get_account_info, process_transaction
+
+__all__ = [
+ "clean_decode",
+ "execute_transaction_request",
+ "fragment_post",
+ "get_account_info",
+ "get_fragment_hash",
+ "parse_json_response",
+ "process_transaction",
+]
diff --git a/pyfragment/utils/decoder.py b/pyfragment/utils/decoder.py
new file mode 100644
index 0000000..451a7b6
--- /dev/null
+++ b/pyfragment/utils/decoder.py
@@ -0,0 +1,35 @@
+import base64
+
+from pytoniq_core import Cell
+
+from pyfragment.types import ParseError
+
+
+def clean_decode(payload: str) -> str:
+ """Decode a base64-encoded BOC payload to a plain-text comment string.
+
+ Fragment transaction payloads are BOC-serialised TVM cells. This function
+ base64-decodes the payload, parses the cell, skips the 32-bit op-code
+ prefix, and reads the snake-encoded UTF-8 comment.
+
+ Args:
+ payload: Base64url-encoded BOC string (padding is added automatically).
+
+ Returns:
+ Decoded comment string, or ``""`` for an empty payload.
+
+ Raises:
+ ParseError: If the payload cannot be decoded or parsed.
+ """
+ s = payload.strip()
+ if not s:
+ return ""
+ s += "=" * (-len(s) % 4)
+ try:
+ boc = base64.b64decode(s)
+ 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()
+ except Exception as exc:
+ raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
diff --git a/pyfragment/utils/http.py b/pyfragment/utils/http.py
new file mode 100644
index 0000000..a1a5e1f
--- /dev/null
+++ b/pyfragment/utils/http.py
@@ -0,0 +1,134 @@
+import re
+from typing import Any
+
+import httpx
+
+from pyfragment.types import FragmentPageError, ParseError, VerificationError
+
+
+async def get_fragment_hash(
+ cookies: dict[str, Any],
+ headers: dict[str, str],
+ page_url: str,
+) -> str:
+ """Fetch the API hash from a Fragment page.
+
+ Fragment embeds a short-lived hash in each page's HTML that must be
+ included in every subsequent API request. This function loads the page
+ as a real browser navigation (not XHR) so Fragment returns full HTML.
+
+ Args:
+ cookies: Active Fragment session cookies.
+ headers: Base headers for the relevant Fragment page.
+ page_url: URL of the Fragment page to fetch the hash from.
+
+ Returns:
+ Lowercase hex hash string.
+
+ Raises:
+ FragmentPageError: If the page returns a non-200 status or the hash
+ is not found in the response HTML.
+ """
+ page_headers = {
+ k: v
+ for k, v in headers.items()
+ if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
+ }
+ page_headers.update(
+ {
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "referer": "https://fragment.com/",
+ "sec-fetch-dest": "document",
+ "sec-fetch-mode": "navigate",
+ "upgrade-insecure-requests": "1",
+ }
+ )
+
+ async with httpx.AsyncClient(cookies=cookies) as session:
+ response = await session.get(page_url, headers=page_headers)
+
+ if response.status_code != 200:
+ raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
+
+ match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
+ if not match:
+ raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
+
+ return match.group(1)
+
+
+def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
+ """Parse a Fragment API JSON response.
+
+ Args:
+ response: The HTTP response object.
+ context: Human-readable name of the API method, used in error messages.
+
+ Returns:
+ Parsed response as a dict.
+
+ Raises:
+ ParseError: If the response body cannot be decoded as JSON.
+ """
+ try:
+ return response.json()
+ except Exception as exc:
+ raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
+
+
+async def fragment_post(
+ session: httpx.AsyncClient,
+ fragment_hash: str,
+ headers: dict[str, str],
+ data: dict[str, Any],
+) -> dict[str, Any]:
+ """POST a single request to the Fragment API.
+
+ Builds the ``/api?hash=`` URL, sends the request, and returns the
+ parsed JSON body. Use this for every API method call — search,
+ init, state updates, etc.
+
+ Args:
+ session: Active httpx session with Fragment cookies.
+ fragment_hash: Short-lived hash from the Fragment page HTML.
+ headers: Page-specific HTTP headers.
+ data: Form data payload; must include a ``"method"`` key.
+
+ Returns:
+ Parsed API response as a dict.
+ """
+ resp = await session.post(
+ f"https://fragment.com/api?hash={fragment_hash}",
+ headers=headers,
+ data=data,
+ )
+ return parse_json_response(resp, data.get("method", "request"))
+
+
+async def execute_transaction_request(
+ session: httpx.AsyncClient,
+ headers: dict,
+ tx_data: dict[str, Any],
+ fragment_hash: str,
+) -> dict[str, Any]:
+ """Post a transaction request to the Fragment API.
+
+ Args:
+ session: Active httpx session with Fragment cookies.
+ headers: Page-specific HTTP headers.
+ tx_data: Form data payload for the API method.
+ fragment_hash: Short-lived hash from the Fragment page.
+
+ Returns:
+ Parsed API response dict containing transaction data.
+
+ Raises:
+ VerificationError: If Fragment requires KYC verification.
+ ParseError: If the response cannot be parsed.
+ """
+ transaction = await fragment_post(session, fragment_hash, headers, tx_data)
+
+ if transaction.get("need_verify"):
+ raise VerificationError(VerificationError.KYC_REQUIRED)
+
+ return transaction
diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py
new file mode 100644
index 0000000..55680f2
--- /dev/null
+++ b/pyfragment/utils/wallet.py
@@ -0,0 +1,135 @@
+import asyncio
+import base64
+from typing import TYPE_CHECKING, Any
+
+from tonutils.clients import TonapiClient
+from tonutils.exceptions import ProviderResponseError
+from tonutils.types import NetworkGlobalID
+
+from pyfragment.types import MIN_TON_BALANCE, WALLET_CLASSES, TransactionError, WalletError
+from pyfragment.types.results import WalletInfo
+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:
+ """Sign and broadcast a Fragment transaction to the TON network.
+
+ Validates the payload structure, checks the wallet balance, decodes the
+ on-chain comment, and calls ``wallet.transfer``.
+
+ Args:
+ client: Authenticated :class:`FragmentClient` instance.
+ transaction_data: Raw transaction dict from ``execute_transaction_request``.
+
+ Returns:
+ Normalised transaction hash string.
+
+ Raises:
+ TransactionError: If the payload is malformed or the broadcast fails.
+ WalletError: If the wallet balance is too low or cannot be fetched.
+ """
+ if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
+ 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'.
+ # This happens when the previous transaction's seqno hasn't been confirmed on-chain yet,
+ # causing the wallet contract to reject the new message.
+ async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
+ wallet_cls = WALLET_CLASSES[client.wallet_version]
+ wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
+
+ # Check balance covers transaction amount + gas reserve
+ 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))
+ except WalletError:
+ raise
+ except Exception as exc:
+ raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc
+
+ try:
+ payload = clean_decode(message["payload"])
+
+ for attempt in range(2):
+ try:
+ result = await wallet.transfer(
+ destination=message["address"],
+ amount=int(message["amount"]), # nanotons, not TON
+ body=payload,
+ )
+ return result.normalized_hash
+ except ProviderResponseError as exc:
+ if exc.code == 429 and attempt == 0:
+ await asyncio.sleep(1)
+ continue
+ raise
+ except (WalletError, TransactionError):
+ raise
+ except Exception as exc:
+ raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
+
+
+async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
+ """Fetch wallet address, public key, and state-init for the Fragment API.
+
+ Fragment requires account info to build each transaction payload. The
+ returned dict is JSON-serialised and passed as the ``account`` field in
+ ``getBuy*Link`` / ``get*Link`` requests.
+
+ Args:
+ client: Authenticated :class:`FragmentClient` instance.
+
+ Returns:
+ Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``.
+
+ Raises:
+ WalletError: If account info cannot be retrieved.
+ """
+ async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
+ try:
+ wallet_cls = WALLET_CLASSES[client.wallet_version]
+ wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
+ boc = wallet.state_init.serialize().to_boc()
+ return {
+ "address": wallet.address.to_str(False, False),
+ "publicKey": pub_key.as_hex,
+ "chain": "-239",
+ "walletStateInit": base64.b64encode(boc).decode(),
+ }
+ except Exception as 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
diff --git a/pyproject.toml b/pyproject.toml
index 48cb023..1f41d60 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,52 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "pyfragment"
+version = "2026.0.1"
+description = "Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance."
+readme = "README.md"
+license = { text = "MIT" }
+requires-python = ">=3.12"
+authors = [{ name = "bohd4nx", url = "https://github.com/bohd4nx" }]
+keywords = ["fragment", "telegram", "ton", "stars", "premium", "crypto", "blockchain"]
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Natural Language :: English",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.12",
+ "Framework :: AsyncIO",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Topic :: Internet",
+ "Topic :: Office/Business :: Financial",
+ "Typing :: Typed",
+]
+dependencies = [
+ "httpx==0.28.1",
+ "tonutils[pytoniq]==2.0.4",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest==9.0.2",
+ "pytest-asyncio==1.3.0",
+ "ruff",
+ "black",
+]
+
+[project.urls]
+Homepage = "https://github.com/bohd4nx/pyfragment"
+Repository = "https://github.com/bohd4nx/pyfragment"
+Issues = "https://github.com/bohd4nx/pyfragment/issues"
+Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
+
+[tool.hatch.build.targets.wheel]
+packages = ["pyfragment"]
+
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["[0-9][0-9][0-9]_test_*.py"]
@@ -9,11 +58,13 @@ line-length = 128
target-version = ["py312"]
[tool.ruff]
-target-version = "py312"
line-length = 128
+target-version = "py312"
[tool.ruff.lint]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort
select = ["E", "F", "W", "I"]
-# E501 — line too long (covered by line-length above)
ignore = ["E501"]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/*" = ["E402"]
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..aefbcb6
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1 @@
+-e .[dev]
diff --git a/requirements.txt b/requirements.txt
index ab531a7..d6e1198 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1 @@
-python-dotenv==1.2.2
-asyncio==4.0.0
-httpx==0.28.1
-tonutils[pytoniq]==2.0.0
+-e .
diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py
index 408de53..170fa1e 100644
--- a/tests/001_test_decode.py
+++ b/tests/001_test_decode.py
@@ -1,11 +1,11 @@
-"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to
-human-readable UTF-8 with the Telegram label and Ref# intact."""
+"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to UTF-8."""
import re
import pytest
-from app.utils.decoder import clean_decode
+from pyfragment.types import ParseError
+from pyfragment.utils.decoder import clean_decode
PAYLOADS = [
pytest.param(
@@ -24,12 +24,17 @@ PAYLOADS = [
@pytest.mark.parametrize("payload", PAYLOADS)
-def test_payload(payload: str) -> None:
+def test_decode_payload(payload: str) -> 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) <= 127 for c in result), f"non-ASCII chars in {result!r}"
+ assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}"
-def test_empty_input_returns_string() -> None:
- assert isinstance(clean_decode(""), str)
+def test_empty_payload_returns_empty_string() -> None:
+ assert clean_decode("") == ""
+
+
+def test_invalid_payload_raises_parse_error() -> None:
+ with pytest.raises(ParseError):
+ clean_decode("!!!not-valid-base64!!!")
diff --git a/tests/002_test_client.py b/tests/002_test_client.py
new file mode 100644
index 0000000..ccf10fe
--- /dev/null
+++ b/tests/002_test_client.py
@@ -0,0 +1,113 @@
+"""Unit tests for FragmentClient — init validation and cookie parsing (no network calls)."""
+
+import json
+
+import pytest
+
+from pyfragment import FragmentClient
+from pyfragment.types import ConfigurationError, CookieError
+
+VALID_SEED = "abandon " * 23 + "about"
+VALID_API_KEY = "A" * 68
+VALID_COOKIES = {
+ "stel_ssid": "x",
+ "stel_dt": "x",
+ "stel_token": "x",
+ "stel_ton_token": "x",
+}
+
+
+def test_valid_init() -> None:
+ client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
+ assert client.seed == VALID_SEED.strip()
+ assert client.api_key == VALID_API_KEY
+ assert client.wallet_version == "V5R1"
+
+
+def test_wallet_version_v4r2() -> None:
+ client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V4R2")
+ assert client.wallet_version == "V4R2"
+
+
+def test_wallet_version_is_case_insensitive() -> None:
+ client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="v5r1")
+ assert client.wallet_version == "V5R1"
+
+
+def test_missing_seed_raises() -> None:
+ with pytest.raises(ConfigurationError):
+ FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
+
+
+def test_whitespace_only_seed_raises() -> None:
+ with pytest.raises(ConfigurationError):
+ FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
+
+
+def test_missing_api_key_raises() -> None:
+ with pytest.raises(ConfigurationError):
+ FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
+
+
+def test_unsupported_wallet_version_raises() -> None:
+ with pytest.raises(ConfigurationError):
+ FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V3R2")
+
+
+def test_cookies_as_json_string() -> None:
+ client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=json.dumps(VALID_COOKIES))
+ assert client.cookies == VALID_COOKIES
+
+
+def test_invalid_cookies_json_raises() -> None:
+ with pytest.raises(CookieError):
+ FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies="{not valid json}")
+
+
+def test_missing_cookie_key_raises() -> None:
+ with pytest.raises(CookieError):
+ FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies={"stel_ssid": "x"})
+
+
+def test_empty_cookie_value_raises() -> None:
+ bad = {**VALID_COOKIES, "stel_token": ""}
+ with pytest.raises(CookieError):
+ FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad)
+
+
+def test_whitespace_cookie_value_raises() -> None:
+ bad = {**VALID_COOKIES, "stel_ton_token": " "}
+ with pytest.raises(CookieError):
+ 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
+
+
+def test_short_api_key_raises() -> None:
+ with pytest.raises(ConfigurationError):
+ FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES)
+
+
+def test_repr() -> None:
+ client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
+ r = repr(client)
+ assert "FragmentClient" in r
+ assert "V5R1" in r
+ assert "4 keys" in r
+
+
+@pytest.mark.asyncio
+async def test_async_context_manager() -> None:
+ async with FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) as client:
+ assert isinstance(client, FragmentClient)
diff --git a/tests/002_test_hash.py b/tests/003_test_hash.py
similarity index 82%
rename from tests/002_test_hash.py
rename to tests/003_test_hash.py
index b1b50ec..4bb6f18 100644
--- a/tests/002_test_hash.py
+++ b/tests/003_test_hash.py
@@ -5,8 +5,8 @@ import re
import pytest
-from app.core.constants import BASE_HEADERS, STARS_PAGE
-from app.utils.hash import get_fragment_hash
+from pyfragment.types import BASE_HEADERS, STARS_PAGE
+from pyfragment.utils import get_fragment_hash
@pytest.mark.asyncio
diff --git a/tests/004_test_balance.py b/tests/004_test_balance.py
new file mode 100644
index 0000000..e2023ee
--- /dev/null
+++ b/tests/004_test_balance.py
@@ -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": {}})
diff --git a/tests/005_test_methods.py b/tests/005_test_methods.py
new file mode 100644
index 0000000..5a7f019
--- /dev/null
+++ b/tests/005_test_methods.py
@@ -0,0 +1,65 @@
+"""Unit tests for method-level input validation — no network calls."""
+
+import pytest
+
+from pyfragment import FragmentClient
+from pyfragment.types import ConfigurationError
+
+VALID_SEED = "abandon " * 23 + "about"
+VALID_API_KEY = "A" * 68
+VALID_COOKIES = {
+ "stel_ssid": "x",
+ "stel_dt": "x",
+ "stel_token": "x",
+ "stel_ton_token": "x",
+}
+
+
+@pytest.fixture
+def client() -> FragmentClient:
+ return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
+
+
+@pytest.mark.asyncio
+async def test_purchase_premium_invalid_months_raises(client: FragmentClient) -> None:
+ with pytest.raises(ConfigurationError):
+ await client.purchase_premium("@user", months=5)
+
+
+@pytest.mark.asyncio
+async def test_purchase_premium_valid_months(client: FragmentClient) -> None:
+ """Validation passes for 3/6/12 — network error expected, not ConfigurationError."""
+ for months in (3, 6, 12):
+ with pytest.raises(Exception) as exc_info:
+ await client.purchase_premium("@user", months=months)
+ assert not isinstance(exc_info.value, ConfigurationError)
+
+
+@pytest.mark.asyncio
+async def test_purchase_stars_amount_too_low_raises(client: FragmentClient) -> None:
+ with pytest.raises(ConfigurationError):
+ await client.purchase_stars("@user", amount=49)
+
+
+@pytest.mark.asyncio
+async def test_purchase_stars_amount_too_high_raises(client: FragmentClient) -> None:
+ with pytest.raises(ConfigurationError):
+ await client.purchase_stars("@user", amount=1_000_001)
+
+
+@pytest.mark.asyncio
+async def test_purchase_stars_float_raises(client: FragmentClient) -> None:
+ with pytest.raises(ConfigurationError):
+ await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type]
+
+
+@pytest.mark.asyncio
+async def test_topup_ton_amount_zero_raises(client: FragmentClient) -> None:
+ with pytest.raises(ConfigurationError):
+ await client.topup_ton("@user", amount=0)
+
+
+@pytest.mark.asyncio
+async def test_topup_ton_amount_too_high_raises(client: FragmentClient) -> None:
+ with pytest.raises(ConfigurationError):
+ await client.topup_ton("@user", amount=1_000_000_001)
diff --git a/tests/conftest.py b/tests/conftest.py
index ac14129..9b60a88 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,13 +1,17 @@
-import pytest
+import json
+from pathlib import Path
-from app.core.cookies import load_cookies
-from app.core.exceptions import CookiesError
+import pytest
@pytest.fixture
def cookies():
- """Load Fragment cookies; skip the test if they are unavailable."""
+ """Load Fragment cookies from cookies.json; skip the test if unavailable."""
+ cookies_path = Path(__file__).resolve().parents[1] / "cookies.json"
+ if not cookies_path.exists():
+ pytest.skip("cookies.json not found")
try:
- return load_cookies()
- except CookiesError as exc:
+ with cookies_path.open("r", encoding="utf-8") as f:
+ return json.load(f)
+ except Exception as exc:
pytest.skip(f"Cookies unavailable — {exc}")