diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 0000000..230a733 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,101 @@ +name: Bug report +description: Report an issue or unexpected behavior in FragmentAPI. +labels: + - bug +body: + - type: checkboxes + attributes: + label: Checklist + options: + - label: I am sure the error is coming from FragmentAPI 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: FragmentAPI version + description: Run `pip show fragmentapi` 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 gifted and StarsResult returned. + validations: + required: true + + - type: textarea + attributes: + label: Current behavior + description: Describe what is actually happening. + placeholder: e.g. RequestError 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 gift_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 fragmentapi import FragmentClient + + async def main(): + client = FragmentClient(...) + result = await client.gift_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 + ... + fragmentapi.types.RequestError: ... + 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..7fc7197 --- /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/FragmentAPI/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..ae3d319 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yaml @@ -0,0 +1,51 @@ +name: Feature request +description: Suggest an improvement or new feature for FragmentAPI. +labels: + - enhancement +body: + - type: dropdown + attributes: + label: FragmentAPI 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..631b583 --- /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: +* FragmentAPI 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/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ed1ead3 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,31 @@ +name: Lint + +on: + push: + branches: [master] + pull_request: + branches: [master] + +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" + + - name: Install uv + uses: astral-sh/setup-uv@v7.5.0 + + - name: Install dev dependencies + run: uv pip install --system ".[dev]" + + - name: Run ruff + run: ruff check . + + - name: Run black + run: black --check . --target-version py312 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..bc243b9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,49 @@ +name: Publish to PyPI + +on: + push: + branches: [master] + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6.0.2 + + - uses: actions/setup-python@v6.2.0 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v7.5.0 + + - name: Build distribution + run: uv build + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/* + + publish: + name: Publish + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/fragmentapi/ + permissions: + id-token: write + + steps: + - name: Download artifacts + uses: actions/download-artifact@v8.0.1 + with: + name: dist + path: dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@v1.13.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d698a55..4edd34f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,18 +8,21 @@ on: jobs: test: + name: Run tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v6.2.0 with: python-version: "3.12" - cache: "pip" + + - name: Install uv + uses: astral-sh/setup-uv@v7.5.0 - name: Install dependencies - run: pip install -r requirements.txt pytest pytest-asyncio + run: uv pip install --system ".[dev]" - name: Write cookies.json if: ${{ env.COOKIES_JSON != '' }} diff --git a/.gitignore b/.gitignore index 9cb8f25..082dca9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,16 @@ logs/ .DS_Store Thumbs.db cookies.json + +# Testing & tooling artifacts +.hypothesis/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Build & distribution +dist/ +build/ +*.egg-info/ diff --git a/.hypothesis/unicode_data/15.0.0/charmap.json.gz b/.hypothesis/unicode_data/15.0.0/charmap.json.gz deleted file mode 100644 index c740c02..0000000 Binary files a/.hypothesis/unicode_data/15.0.0/charmap.json.gz and /dev/null differ diff --git a/fragmentapi/methods/premium.py b/fragmentapi/methods/premium.py index b409d63..4f5cce6 100644 --- a/fragmentapi/methods/premium.py +++ b/fragmentapi/methods/premium.py @@ -17,9 +17,9 @@ from fragmentapi.types import ( ) from fragmentapi.utils import ( execute_transaction_request, + fragment_post, get_account_info, get_fragment_hash, - parse_json_response, process_transaction, ) @@ -40,16 +40,16 @@ async def _search_recipient( username: str, months: int, ) -> str: - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { "query": username, "months": months, "method": "searchPremiumGiftRecipient", }, ) - result = parse_json_response(resp, "searchPremiumGiftRecipient") recipient = result.get("found", {}).get("recipient") if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) @@ -62,26 +62,27 @@ async def _init_request( recipient: str, months: int, ) -> str: - await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + await fragment_post( + session, + fragment_hash, + HEADERS, + { "mode": "new", "lv": "false", "dh": str(int(time.time())), "method": "updatePremiumState", }, ) - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { "recipient": recipient, "months": months, "method": "initGiftPremiumRequest", }, ) - result = parse_json_response(resp, "initGiftPremiumRequest") req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase")) diff --git a/fragmentapi/methods/stars.py b/fragmentapi/methods/stars.py index 36bd271..9f15853 100644 --- a/fragmentapi/methods/stars.py +++ b/fragmentapi/methods/stars.py @@ -16,9 +16,9 @@ from fragmentapi.types import ( ) from fragmentapi.utils import ( execute_transaction_request, + fragment_post, get_account_info, get_fragment_hash, - parse_json_response, process_transaction, ) @@ -38,12 +38,16 @@ async def _search_recipient( fragment_hash: str, username: str, ) -> str: - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={"query": username, "quantity": "", "method": "searchStarsRecipient"}, + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { + "query": username, + "quantity": "", + "method": "searchStarsRecipient", + }, ) - result = parse_json_response(resp, "searchStarsRecipient") recipient = result.get("found", {}).get("recipient") if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) @@ -56,16 +60,16 @@ async def _init_request( recipient: str, amount: int, ) -> str: - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { "recipient": recipient, "quantity": amount, "method": "initBuyStarsRequest", }, ) - result = parse_json_response(resp, "initBuyStarsRequest") req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase")) diff --git a/fragmentapi/methods/ton.py b/fragmentapi/methods/ton.py index 325d8e0..e4556cc 100644 --- a/fragmentapi/methods/ton.py +++ b/fragmentapi/methods/ton.py @@ -16,9 +16,9 @@ from fragmentapi.types import ( ) from fragmentapi.utils import ( execute_transaction_request, + fragment_post, get_account_info, get_fragment_hash, - parse_json_response, process_transaction, ) @@ -38,17 +38,16 @@ async def _search_recipient( fragment_hash: str, username: str, ) -> str: - await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={"mode": "new", "method": "updateAdsTopupState"}, + await fragment_post(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"}) + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { + "query": username, + "method": "searchAdsTopupRecipient", + }, ) - resp = await session.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(UserNotFoundError.NOT_FOUND.format(username=username)) @@ -61,16 +60,16 @@ async def _init_request( recipient: str, amount: int, ) -> str: - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { "recipient": recipient, "amount": amount, "method": "initAdsTopupRequest", }, ) - result = parse_json_response(resp, "initAdsTopupRequest") req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup")) diff --git a/fragmentapi/types/__init__.py b/fragmentapi/types/__init__.py index addcddd..13b69d4 100644 --- a/fragmentapi/types/__init__.py +++ b/fragmentapi/types/__init__.py @@ -1,6 +1,7 @@ from fragmentapi.types.constants import ( BASE_HEADERS, DEVICE, + MIN_TON_BALANCE, PREMIUM_PAGE, REQUIRED_COOKIE_KEYS, STARS_PAGE, @@ -30,6 +31,7 @@ __all__ = [ # constants "BASE_HEADERS", "DEVICE", + "MIN_TON_BALANCE", "PREMIUM_PAGE", "REQUIRED_COOKIE_KEYS", "STARS_PAGE", diff --git a/fragmentapi/types/constants.py b/fragmentapi/types/constants.py index 073d1e7..2bb799d 100644 --- a/fragmentapi/types/constants.py +++ b/fragmentapi/types/constants.py @@ -10,6 +10,9 @@ 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") diff --git a/fragmentapi/utils/__init__.py b/fragmentapi/utils/__init__.py index c570db8..a888ac8 100644 --- a/fragmentapi/utils/__init__.py +++ b/fragmentapi/utils/__init__.py @@ -1,14 +1,16 @@ -from fragmentapi.utils.client import ( +from fragmentapi.utils.decoder import clean_decode +from fragmentapi.utils.http import ( execute_transaction_request, + fragment_post, get_fragment_hash, parse_json_response, ) -from fragmentapi.utils.decoder import clean_decode from fragmentapi.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", diff --git a/fragmentapi/utils/decoder.py b/fragmentapi/utils/decoder.py index 43a9e75..77e076a 100644 --- a/fragmentapi/utils/decoder.py +++ b/fragmentapi/utils/decoder.py @@ -6,6 +6,21 @@ from fragmentapi.types import RequestError 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: + RequestError: If the payload cannot be decoded or parsed. + """ s = payload.strip() if not s: return "" diff --git a/fragmentapi/utils/client.py b/fragmentapi/utils/http.py similarity index 76% rename from fragmentapi/utils/client.py rename to fragmentapi/utils/http.py index 53be7bc..0874645 100644 --- a/fragmentapi/utils/client.py +++ b/fragmentapi/utils/http.py @@ -76,6 +76,35 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any raise RequestError(RequestError.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, @@ -97,9 +126,7 @@ async def execute_transaction_request( VerificationError: If Fragment requires KYC verification. RequestError: If the response cannot be parsed. """ - url = f"https://fragment.com/api?hash={fragment_hash}" - resp = await session.post(url, headers=headers, data=tx_data) - transaction = parse_json_response(resp, tx_data.get("method", "transaction")) + transaction = await fragment_post(session, fragment_hash, headers, tx_data) if transaction.get("need_verify"): raise VerificationError(VerificationError.KYC_REQUIRED) diff --git a/fragmentapi/utils/wallet.py b/fragmentapi/utils/wallet.py index e852bdc..01a0939 100644 --- a/fragmentapi/utils/wallet.py +++ b/fragmentapi/utils/wallet.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any from tonutils.clients import TonapiClient from tonutils.types import NetworkGlobalID -from fragmentapi.types import WALLET_CLASSES, TransactionError, WalletError +from fragmentapi.types import MIN_TON_BALANCE, WALLET_CLASSES, TransactionError, WalletError from fragmentapi.utils.decoder import clean_decode if TYPE_CHECKING: @@ -16,6 +16,22 @@ def _init_ton_client(client: "FragmentClient") -> TonapiClient: 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) @@ -30,7 +46,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) try: await wallet.refresh() balance_ton = wallet.balance / 1_000_000_000 - if balance_ton < 0.056: + if balance_ton < MIN_TON_BALANCE: raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton)) except WalletError: raise @@ -54,6 +70,21 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) 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 _init_ton_client(client) as ton: try: wallet_cls = WALLET_CLASSES[client.wallet_version] diff --git a/pyproject.toml b/pyproject.toml index 3a407fb..d62ec9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,14 @@ dependencies = [ "tonutils[pytoniq]==2.0.0", ] +[project.optional-dependencies] +dev = [ + "pytest>=9.0", + "pytest-asyncio>=1.0", + "ruff", + "black", +] + [project.urls] Homepage = "https://github.com/bohd4nx/FragmentAPI" Repository = "https://github.com/bohd4nx/FragmentAPI" @@ -49,11 +57,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 5d930d9..d6e1198 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -httpx==0.28.1 -tonutils[pytoniq]==2.0.0 +-e .