tonflow 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tonflow/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """TON blockchain parsing and local indexing toolkit."""
2
+
3
+ from tonflow.addresses import (
4
+ is_raw_address,
5
+ is_user_friendly_address,
6
+ normalize_address,
7
+ validate_address,
8
+ )
9
+ from tonflow.cache import InMemoryCache, JSONCache, SQLiteCache
10
+ from tonflow.client import TonClient
11
+ from tonflow.export import (
12
+ jetton_transfers_to_csv,
13
+ jetton_transfers_to_json,
14
+ transactions_to_csv,
15
+ transactions_to_json,
16
+ )
17
+ from tonflow.jettons import (
18
+ decode_jetton_transfer,
19
+ extract_jetton_transfers,
20
+ is_jetton_burn,
21
+ is_jetton_transfer,
22
+ is_jetton_transfer_notification,
23
+ normalize_amount,
24
+ )
25
+ from tonflow.models import (
26
+ JettonTransfer,
27
+ Message,
28
+ MessageDirection,
29
+ RawPayload,
30
+ TonflowModel,
31
+ Transaction,
32
+ TransactionStatus,
33
+ )
34
+ from tonflow.stream import watch_address
35
+
36
+ __all__ = [
37
+ "InMemoryCache",
38
+ "JSONCache",
39
+ "JettonTransfer",
40
+ "Message",
41
+ "MessageDirection",
42
+ "RawPayload",
43
+ "TonClient",
44
+ "TonflowModel",
45
+ "Transaction",
46
+ "TransactionStatus",
47
+ "SQLiteCache",
48
+ "decode_jetton_transfer",
49
+ "extract_jetton_transfers",
50
+ "is_jetton_burn",
51
+ "is_jetton_transfer",
52
+ "is_jetton_transfer_notification",
53
+ "normalize_amount",
54
+ "watch_address",
55
+ "jetton_transfers_to_csv",
56
+ "jetton_transfers_to_json",
57
+ "transactions_to_csv",
58
+ "transactions_to_json",
59
+ "is_raw_address",
60
+ "is_user_friendly_address",
61
+ "normalize_address",
62
+ "validate_address",
63
+ ]
tonflow/addresses.py ADDED
@@ -0,0 +1,70 @@
1
+ """Address helpers for TON account identifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import re
7
+
8
+ # User-friendly address: 48 base64url chars, first byte encodes workchain + flags.
9
+ # EQ = bounceable mainnet (workchain 0)
10
+ # UQ = non-bounceable mainnet (workchain 0)
11
+ # kQ = bounceable testnet (workchain 0)
12
+ # 0Q = non-bounceable testnet (workchain 0)
13
+ _USER_FRIENDLY_PREFIXES = ("EQ", "UQ", "kQ", "0Q")
14
+ _USER_FRIENDLY_LENGTH = 48
15
+ _USER_FRIENDLY_RE = re.compile(r"^[A-Za-z0-9_\-]{48}$")
16
+
17
+ # Raw address: "<workchain>:<64 hex chars>"
18
+ _RAW_RE = re.compile(r"^-?[0-9]+:[0-9a-fA-F]{64}$")
19
+
20
+
21
+ def normalize_address(address: str) -> str:
22
+ """Return the trimmed TON address, raising ValueError if blank."""
23
+ normalized = address.strip()
24
+ if not normalized:
25
+ raise ValueError("TON address cannot be empty.")
26
+ return normalized
27
+
28
+
29
+ def is_user_friendly_address(address: str) -> bool:
30
+ """Return True if *address* looks like a valid TON user-friendly address.
31
+
32
+ Checks:
33
+ - length is exactly 48 characters
34
+ - characters are valid base64url (A-Z, a-z, 0-9, ``-``, ``_``)
35
+ - starts with a known workchain prefix (EQ, UQ, kQ, 0Q)
36
+ - base64-decoded payload is exactly 36 bytes (tag + workchain + hash + crc)
37
+ """
38
+ addr = address.strip()
39
+ if len(addr) != _USER_FRIENDLY_LENGTH:
40
+ return False
41
+ if not addr.startswith(_USER_FRIENDLY_PREFIXES):
42
+ return False
43
+ if not _USER_FRIENDLY_RE.match(addr):
44
+ return False
45
+ try:
46
+ decoded = base64.urlsafe_b64decode(addr + "==")
47
+ except Exception:
48
+ return False
49
+ return len(decoded) == 36
50
+
51
+
52
+ def is_raw_address(address: str) -> bool:
53
+ """Return True if *address* is a TON raw address (``workchain:hex``)."""
54
+ return bool(_RAW_RE.match(address.strip()))
55
+
56
+
57
+ def validate_address(address: str) -> str:
58
+ """Return the trimmed address if it is a recognized TON format.
59
+
60
+ Raises:
61
+ ValueError: if the address is neither user-friendly nor raw.
62
+ """
63
+ addr = normalize_address(address)
64
+ if is_user_friendly_address(addr) or is_raw_address(addr):
65
+ return addr
66
+ raise ValueError(
67
+ f"'{addr}' is not a valid TON address. "
68
+ "Expected a 48-char user-friendly address (EQ/UQ/kQ/0Q...) "
69
+ "or a raw address (workchain:64hexchars)."
70
+ )
tonflow/cache.py ADDED
@@ -0,0 +1,120 @@
1
+ """Small cache primitives used by clients and streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from time import monotonic, time
10
+ from typing import Protocol, TypeVar
11
+
12
+ from tonflow.models import RawPayload
13
+
14
+ T = TypeVar("T")
15
+
16
+
17
+ class JSONCache(Protocol):
18
+ """Cache backend interface for JSON-compatible API responses."""
19
+
20
+ def get(self, key: str) -> RawPayload | None:
21
+ """Return a cached JSON payload or None when it is missing/expired."""
22
+
23
+ def set(self, key: str, value: RawPayload, ttl_seconds: float | None = None) -> None:
24
+ """Store a JSON payload with an optional TTL."""
25
+
26
+ def clear(self) -> None:
27
+ """Remove all cached values."""
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class _CacheEntry[T]:
32
+ value: T
33
+ expires_at: float | None
34
+
35
+
36
+ class InMemoryCache:
37
+ """Simple TTL cache for tests, examples, and short-lived scripts."""
38
+
39
+ def __init__(self) -> None:
40
+ self._items: dict[str, _CacheEntry[object]] = {}
41
+
42
+ def get[T](self, key: str) -> T | None:
43
+ entry = self._items.get(key)
44
+ if entry is None:
45
+ return None
46
+ if entry.expires_at is not None and entry.expires_at <= monotonic():
47
+ self._items.pop(key, None)
48
+ return None
49
+ return entry.value # type: ignore[return-value]
50
+
51
+ def set(self, key: str, value: object, ttl_seconds: float | None = None) -> None:
52
+ expires_at = None if ttl_seconds is None else monotonic() + ttl_seconds
53
+ self._items[key] = _CacheEntry(value=value, expires_at=expires_at)
54
+
55
+ def clear(self) -> None:
56
+ self._items.clear()
57
+
58
+
59
+ class SQLiteCache:
60
+ """SQLite-backed TTL cache for local scripts and small services."""
61
+
62
+ def __init__(self, path: str | Path) -> None:
63
+ self.path = Path(path)
64
+ self.path.parent.mkdir(parents=True, exist_ok=True)
65
+ self._initialize()
66
+
67
+ def get(self, key: str) -> RawPayload | None:
68
+ now = time()
69
+ with self._connect() as connection:
70
+ row = connection.execute(
71
+ "SELECT value, expires_at FROM cache_entries WHERE key = ?",
72
+ (key,),
73
+ ).fetchone()
74
+ if row is None:
75
+ return None
76
+
77
+ value, expires_at = row
78
+ if expires_at is not None and expires_at <= now:
79
+ connection.execute("DELETE FROM cache_entries WHERE key = ?", (key,))
80
+ return None
81
+
82
+ data = json.loads(value)
83
+ if not isinstance(data, dict):
84
+ msg = "Cached payload must be a JSON object."
85
+ raise ValueError(msg)
86
+ return data
87
+
88
+ def set(self, key: str, value: RawPayload, ttl_seconds: float | None = None) -> None:
89
+ expires_at = None if ttl_seconds is None else time() + ttl_seconds
90
+ encoded = json.dumps(value, sort_keys=True, separators=(",", ":"))
91
+ with self._connect() as connection:
92
+ connection.execute(
93
+ """
94
+ INSERT INTO cache_entries (key, value, expires_at)
95
+ VALUES (?, ?, ?)
96
+ ON CONFLICT(key) DO UPDATE SET
97
+ value = excluded.value,
98
+ expires_at = excluded.expires_at
99
+ """,
100
+ (key, encoded, expires_at),
101
+ )
102
+
103
+ def clear(self) -> None:
104
+ with self._connect() as connection:
105
+ connection.execute("DELETE FROM cache_entries")
106
+
107
+ def _initialize(self) -> None:
108
+ with self._connect() as connection:
109
+ connection.execute(
110
+ """
111
+ CREATE TABLE IF NOT EXISTS cache_entries (
112
+ key TEXT PRIMARY KEY,
113
+ value TEXT NOT NULL,
114
+ expires_at REAL
115
+ )
116
+ """
117
+ )
118
+
119
+ def _connect(self) -> sqlite3.Connection:
120
+ return sqlite3.connect(self.path)
tonflow/client.py ADDED
@@ -0,0 +1,328 @@
1
+ """Client entry points for reading TON blockchain data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+ from urllib.parse import urlencode
8
+
9
+ import httpx
10
+
11
+ from tonflow.addresses import normalize_address
12
+ from tonflow.cache import JSONCache
13
+ from tonflow.exceptions import TonflowAPIError, TonflowDecodeError
14
+ from tonflow.jettons import decode_jetton_transfer
15
+ from tonflow.models import (
16
+ JettonTransfer,
17
+ Message,
18
+ MessageDirection,
19
+ RawPayload,
20
+ Transaction,
21
+ TransactionStatus,
22
+ )
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class TonClient:
27
+ """High-level async TON API client."""
28
+
29
+ endpoint: str
30
+ api_key: str | None = None
31
+ timeout: float = 10.0
32
+ http_client: httpx.AsyncClient | None = None
33
+ cache: JSONCache | None = None
34
+ cache_ttl_seconds: float | None = 30.0
35
+ _owns_http_client: bool = field(default=False, init=False, repr=False)
36
+
37
+ async def __aenter__(self) -> TonClient:
38
+ return self
39
+
40
+ async def __aexit__(self, *_exc_info: object) -> None:
41
+ await self.aclose()
42
+
43
+ async def aclose(self) -> None:
44
+ """Close the owned HTTP client, if tonflow created one."""
45
+
46
+ if self.http_client is not None and self._owns_http_client:
47
+ await self.http_client.aclose()
48
+ self.http_client = None
49
+ self._owns_http_client = False
50
+
51
+ async def get_transactions(
52
+ self,
53
+ address: str,
54
+ *,
55
+ limit: int = 20,
56
+ before_lt: int | None = None,
57
+ ) -> list[Transaction]:
58
+ """Fetch and normalize account transactions."""
59
+
60
+ normalized = normalize_address(address)
61
+ if limit <= 0:
62
+ msg = "limit must be greater than zero."
63
+ raise ValueError(msg)
64
+ if before_lt is not None and before_lt < 0:
65
+ msg = "before_lt must be greater than or equal to zero."
66
+ raise ValueError(msg)
67
+
68
+ payload = await self._request_json(
69
+ "GET",
70
+ f"/v2/blockchain/accounts/{normalized}/transactions",
71
+ params={"limit": limit, "before_lt": before_lt},
72
+ )
73
+ transactions = _extract_transactions(payload)
74
+ return [_parse_transaction(item, account=normalized) for item in transactions]
75
+
76
+ async def get_jetton_transfers(
77
+ self,
78
+ address: str,
79
+ *,
80
+ limit: int = 20,
81
+ before_lt: int | None = None,
82
+ decimals: int = 9,
83
+ jetton_minter: str | None = None,
84
+ symbol: str | None = None,
85
+ ) -> list[JettonTransfer]:
86
+ """Fetch transactions for *address* and return only Jetton transfer events.
87
+
88
+ Internally calls :meth:`get_transactions` and filters messages whose
89
+ op_code matches a TEP-74 Jetton transfer or transfer_notification.
90
+
91
+ Args:
92
+ address: TON account address to query.
93
+ limit: Maximum number of transactions to scan (not transfers).
94
+ before_lt: Return transactions with logical time below this value.
95
+ decimals: Token decimal places used for amount normalization.
96
+ jetton_minter: Optional minter contract address to attach to results.
97
+ symbol: Optional token symbol to attach to results.
98
+
99
+ Returns:
100
+ List of :class:`~tonflow.models.JettonTransfer` in the order they
101
+ appear across the fetched transactions (newest transaction first,
102
+ matching the API order).
103
+ """
104
+ transactions = await self.get_transactions(address, limit=limit, before_lt=before_lt)
105
+
106
+ transfers: list[JettonTransfer] = []
107
+ for tx in transactions:
108
+ messages: list[Message] = []
109
+ if tx.in_message is not None:
110
+ messages.append(tx.in_message)
111
+ messages.extend(tx.out_messages)
112
+
113
+ for msg in messages:
114
+ result = decode_jetton_transfer(
115
+ tx,
116
+ msg,
117
+ decimals=decimals,
118
+ jetton_minter=jetton_minter,
119
+ symbol=symbol,
120
+ )
121
+ if result is not None:
122
+ transfers.append(result)
123
+
124
+ return transfers
125
+
126
+ async def _request_json(
127
+ self,
128
+ method: str,
129
+ path: str,
130
+ *,
131
+ params: dict[str, int | None] | None = None,
132
+ ) -> RawPayload:
133
+ clean_params = {key: value for key, value in (params or {}).items() if value is not None}
134
+ cache_key = _cache_key(method, path, clean_params)
135
+ if self.cache is not None and method.upper() == "GET":
136
+ cached = self.cache.get(cache_key)
137
+ if cached is not None:
138
+ return cached
139
+
140
+ client = self._get_http_client()
141
+
142
+ try:
143
+ response = await client.request(method, path, params=clean_params)
144
+ response.raise_for_status()
145
+ except httpx.HTTPStatusError as exc:
146
+ raise TonflowAPIError(
147
+ f"TON API request failed with status {exc.response.status_code}.",
148
+ status_code=exc.response.status_code,
149
+ url=str(exc.request.url),
150
+ ) from exc
151
+ except httpx.HTTPError as exc:
152
+ raise TonflowAPIError(f"TON API request failed: {exc}") from exc
153
+
154
+ try:
155
+ data = response.json()
156
+ except ValueError as exc:
157
+ raise TonflowDecodeError("TON API response is not valid JSON.") from exc
158
+
159
+ if not isinstance(data, dict):
160
+ raise TonflowDecodeError("TON API response must be a JSON object.")
161
+
162
+ if self.cache is not None and method.upper() == "GET":
163
+ self.cache.set(cache_key, data, ttl_seconds=self.cache_ttl_seconds)
164
+ return data
165
+
166
+ def _get_http_client(self) -> httpx.AsyncClient:
167
+ if self.http_client is not None:
168
+ return self.http_client
169
+
170
+ headers = {"Accept": "application/json"}
171
+ if self.api_key is not None:
172
+ headers["Authorization"] = f"Bearer {self.api_key}"
173
+
174
+ self.http_client = httpx.AsyncClient(
175
+ base_url=self.endpoint.rstrip("/"),
176
+ headers=headers,
177
+ timeout=self.timeout,
178
+ )
179
+ self._owns_http_client = True
180
+ return self.http_client
181
+
182
+
183
+ def _cache_key(method: str, path: str, params: dict[str, int]) -> str:
184
+ query = urlencode(sorted(params.items()))
185
+ if query:
186
+ return f"{method.upper()} {path}?{query}"
187
+ return f"{method.upper()} {path}"
188
+
189
+
190
+ def _extract_transactions(payload: RawPayload) -> list[RawPayload]:
191
+ raw_transactions: Any = payload.get("transactions", payload.get("items"))
192
+ if raw_transactions is None:
193
+ raw_transactions = payload.get("result", [])
194
+ if not isinstance(raw_transactions, list):
195
+ raise TonflowDecodeError("TON API transactions field must be a list.")
196
+
197
+ transactions: list[RawPayload] = []
198
+ for item in raw_transactions:
199
+ if not isinstance(item, dict):
200
+ raise TonflowDecodeError("TON API transaction item must be an object.")
201
+ transactions.append(item)
202
+ return transactions
203
+
204
+
205
+ def _parse_transaction(raw: RawPayload, *, account: str) -> Transaction:
206
+ transaction_hash = _required_str(raw, "hash")
207
+ logical_time = _required_int(raw, "lt", fallback_keys=("logical_time",))
208
+
209
+ return Transaction(
210
+ hash=transaction_hash,
211
+ account=account,
212
+ lt=logical_time,
213
+ timestamp=_optional_int(raw, "now", fallback_keys=("timestamp", "utime")),
214
+ status=_parse_status(raw),
215
+ in_message=_parse_message(
216
+ raw.get("in_msg") or raw.get("in_message"), MessageDirection.INBOUND
217
+ ),
218
+ out_messages=tuple(
219
+ _parse_required_message(item, MessageDirection.OUTBOUND)
220
+ for item in _raw_message_list(raw.get("out_msgs") or raw.get("out_messages"))
221
+ ),
222
+ total_fees=_optional_int(raw, "total_fees", fallback_keys=("fee",)),
223
+ raw=raw,
224
+ )
225
+
226
+
227
+ def _parse_message(raw: object, direction: MessageDirection) -> Message | None:
228
+ if raw is None:
229
+ return None
230
+ if not isinstance(raw, dict):
231
+ raise TonflowDecodeError("TON API message must be an object.")
232
+
233
+ return Message(
234
+ source=_optional_str(raw, "source", fallback_keys=("src",)),
235
+ destination=_optional_str(raw, "destination", fallback_keys=("dst",)),
236
+ direction=direction,
237
+ value=_optional_int(raw, "value", fallback_keys=("amount",)),
238
+ body=_optional_body(raw),
239
+ op_code=_optional_int(raw, "op_code", fallback_keys=("opcode",)),
240
+ raw=raw,
241
+ )
242
+
243
+
244
+ def _parse_required_message(raw: object, direction: MessageDirection) -> Message:
245
+ message = _parse_message(raw, direction)
246
+ if message is None:
247
+ raise TonflowDecodeError("TON API message cannot be null in this field.")
248
+ return message
249
+
250
+
251
+ def _raw_message_list(raw: object) -> list[object]:
252
+ if raw is None:
253
+ return []
254
+ if not isinstance(raw, list):
255
+ raise TonflowDecodeError("TON API out messages field must be a list.")
256
+ return raw
257
+
258
+
259
+ def _parse_status(raw: RawPayload) -> TransactionStatus:
260
+ if raw.get("success") is True:
261
+ return TransactionStatus.SUCCESS
262
+ if raw.get("success") is False:
263
+ return TransactionStatus.FAILED
264
+
265
+ status = _optional_str(raw, "status")
266
+ if status in {TransactionStatus.SUCCESS, "ok"}:
267
+ return TransactionStatus.SUCCESS
268
+ if status in {TransactionStatus.FAILED, "error"}:
269
+ return TransactionStatus.FAILED
270
+ return TransactionStatus.UNKNOWN
271
+
272
+
273
+ def _optional_body(raw: RawPayload) -> str | None:
274
+ body = _optional_str(raw, "body")
275
+ if body is not None:
276
+ return body
277
+
278
+ decoded_body = raw.get("decoded_body")
279
+ if isinstance(decoded_body, dict):
280
+ text = decoded_body.get("text") or decoded_body.get("comment")
281
+ if isinstance(text, str):
282
+ return text
283
+ return None
284
+
285
+
286
+ def _required_str(raw: RawPayload, key: str, *, fallback_keys: tuple[str, ...] = ()) -> str:
287
+ value = _optional_str(raw, key, fallback_keys=fallback_keys)
288
+ if value is None:
289
+ raise TonflowDecodeError(f"TON API transaction is missing required field '{key}'.")
290
+ return value
291
+
292
+
293
+ def _optional_str(raw: RawPayload, key: str, *, fallback_keys: tuple[str, ...] = ()) -> str | None:
294
+ value = _first_value(raw, key, fallback_keys)
295
+ if value is None:
296
+ return None
297
+ if not isinstance(value, str):
298
+ raise TonflowDecodeError(f"TON API field '{key}' must be a string.")
299
+ return value
300
+
301
+
302
+ def _required_int(raw: RawPayload, key: str, *, fallback_keys: tuple[str, ...] = ()) -> int:
303
+ value = _optional_int(raw, key, fallback_keys=fallback_keys)
304
+ if value is None:
305
+ raise TonflowDecodeError(f"TON API transaction is missing required field '{key}'.")
306
+ return value
307
+
308
+
309
+ def _optional_int(raw: RawPayload, key: str, *, fallback_keys: tuple[str, ...] = ()) -> int | None:
310
+ value = _first_value(raw, key, fallback_keys)
311
+ if value is None:
312
+ return None
313
+ if isinstance(value, int):
314
+ return value
315
+ if isinstance(value, float) and value.is_integer():
316
+ return int(value)
317
+ if isinstance(value, str) and value.isdecimal():
318
+ return int(value)
319
+ raise TonflowDecodeError(f"TON API field '{key}' must be an integer.")
320
+
321
+
322
+ def _first_value(raw: RawPayload, key: str, fallback_keys: tuple[str, ...]) -> object:
323
+ if key in raw:
324
+ return raw[key]
325
+ for fallback_key in fallback_keys:
326
+ if fallback_key in raw:
327
+ return raw[fallback_key]
328
+ return None
tonflow/exceptions.py ADDED
@@ -0,0 +1,26 @@
1
+ """Custom exceptions raised by tonflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class TonflowError(Exception):
7
+ """Base error for tonflow."""
8
+
9
+
10
+ class TonflowAPIError(TonflowError):
11
+ """Raised when an upstream TON API request fails."""
12
+
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ *,
17
+ status_code: int | None = None,
18
+ url: str | None = None,
19
+ ) -> None:
20
+ super().__init__(message)
21
+ self.status_code = status_code
22
+ self.url = url
23
+
24
+
25
+ class TonflowDecodeError(TonflowError):
26
+ """Raised when blockchain payload decoding fails."""
tonflow/export.py ADDED
@@ -0,0 +1,101 @@
1
+ """Helpers for exporting tonflow models to JSON and CSV."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import json
8
+ from decimal import Decimal
9
+ from typing import Any
10
+
11
+ from tonflow.models import JettonTransfer, Transaction
12
+
13
+
14
+ def _default(obj: object) -> Any:
15
+ if isinstance(obj, Decimal):
16
+ return str(obj)
17
+ raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
18
+
19
+
20
+ def transactions_to_json(transactions: list[Transaction], *, indent: int | None = None) -> str:
21
+ """Serialize a list of transactions to a JSON string.
22
+
23
+ Decimal values are serialized as strings to preserve precision.
24
+ """
25
+ data = [tx.model_dump(mode="json") for tx in transactions]
26
+ return json.dumps(data, default=_default, indent=indent, ensure_ascii=False)
27
+
28
+
29
+ def jetton_transfers_to_json(transfers: list[JettonTransfer], *, indent: int | None = None) -> str:
30
+ """Serialize a list of Jetton transfers to a JSON string."""
31
+ data = [t.model_dump(mode="json") for t in transfers]
32
+ return json.dumps(data, default=_default, indent=indent, ensure_ascii=False)
33
+
34
+
35
+ # CSV column ordering for each model type
36
+ _TRANSACTION_FIELDS = [
37
+ "hash",
38
+ "account",
39
+ "logical_time",
40
+ "timestamp",
41
+ "status",
42
+ "total_fees",
43
+ ]
44
+
45
+ _JETTON_TRANSFER_FIELDS = [
46
+ "transaction_hash",
47
+ "sender",
48
+ "recipient",
49
+ "amount",
50
+ "raw_amount",
51
+ "decimals",
52
+ "symbol",
53
+ "jetton_wallet",
54
+ "jetton_minter",
55
+ "comment",
56
+ ]
57
+
58
+
59
+ def transactions_to_csv(transactions: list[Transaction]) -> str:
60
+ """Serialize a list of transactions to a CSV string.
61
+
62
+ Includes the core scalar fields; nested messages and raw payload are omitted.
63
+ """
64
+ output = io.StringIO()
65
+ writer = csv.DictWriter(output, fieldnames=_TRANSACTION_FIELDS, extrasaction="ignore")
66
+ writer.writeheader()
67
+ for tx in transactions:
68
+ writer.writerow(
69
+ {
70
+ "hash": tx.hash,
71
+ "account": tx.account,
72
+ "logical_time": tx.logical_time,
73
+ "timestamp": tx.timestamp,
74
+ "status": tx.status,
75
+ "total_fees": tx.total_fees,
76
+ }
77
+ )
78
+ return output.getvalue()
79
+
80
+
81
+ def jetton_transfers_to_csv(transfers: list[JettonTransfer]) -> str:
82
+ """Serialize a list of Jetton transfers to a CSV string."""
83
+ output = io.StringIO()
84
+ writer = csv.DictWriter(output, fieldnames=_JETTON_TRANSFER_FIELDS, extrasaction="ignore")
85
+ writer.writeheader()
86
+ for t in transfers:
87
+ writer.writerow(
88
+ {
89
+ "transaction_hash": t.transaction_hash,
90
+ "sender": t.sender,
91
+ "recipient": t.recipient,
92
+ "amount": str(t.amount),
93
+ "raw_amount": t.raw_amount,
94
+ "decimals": t.decimals,
95
+ "symbol": t.symbol,
96
+ "jetton_wallet": t.jetton_wallet,
97
+ "jetton_minter": t.jetton_minter,
98
+ "comment": t.comment,
99
+ }
100
+ )
101
+ return output.getvalue()
tonflow/jettons.py ADDED
@@ -0,0 +1,126 @@
1
+ """Jetton event normalization helpers."""
2
+
3
+ from decimal import Decimal
4
+
5
+ from tonflow.models import JettonTransfer, Message, Transaction
6
+
7
+ # TEP-74 Jetton standard op codes
8
+ OP_JETTON_TRANSFER = 0xF8A7EA5
9
+ OP_JETTON_TRANSFER_NOTIFICATION = 0x7362D09C
10
+ OP_JETTON_INTERNAL_TRANSFER = 0x178D4519
11
+ OP_JETTON_BURN = 0x595F07BC
12
+
13
+
14
+ def normalize_amount(raw_amount: int | str, decimals: int) -> Decimal:
15
+ """Convert a raw Jetton amount into a human-readable decimal value."""
16
+ amount = Decimal(str(raw_amount))
17
+ scale = Decimal(10) ** decimals
18
+ return amount / scale
19
+
20
+
21
+ def is_jetton_transfer(message: Message) -> bool:
22
+ """Return True if the message op_code matches a Jetton transfer."""
23
+ return message.op_code == OP_JETTON_TRANSFER
24
+
25
+
26
+ def is_jetton_transfer_notification(message: Message) -> bool:
27
+ """Return True if op_code matches a Jetton transfer notification."""
28
+ return message.op_code == OP_JETTON_TRANSFER_NOTIFICATION
29
+
30
+
31
+ def is_jetton_burn(message: Message) -> bool:
32
+ """Return True if op_code matches a Jetton burn."""
33
+ return message.op_code == OP_JETTON_BURN
34
+
35
+
36
+ def decode_jetton_transfer(
37
+ transaction: Transaction,
38
+ message: Message,
39
+ *,
40
+ decimals: int = 9,
41
+ jetton_minter: str | None = None,
42
+ symbol: str | None = None,
43
+ ) -> JettonTransfer | None:
44
+ """Parse a Jetton transfer from a transaction message.
45
+
46
+ Returns a JettonTransfer if the message is a recognized Jetton transfer,
47
+ otherwise returns None.
48
+
49
+ Supports both TEP-74 transfer (op 0xf8a7ea5) and transfer_notification
50
+ (op 0x7362d09c). The raw payload is expected to carry the token amount
51
+ under the key ``"amount"`` and optionally ``"comment"`` / ``"forward_payload"``.
52
+ """
53
+ if message.op_code not in (OP_JETTON_TRANSFER, OP_JETTON_TRANSFER_NOTIFICATION):
54
+ return None
55
+
56
+ raw = message.raw
57
+
58
+ # Amount may come from the message value or an explicit ``amount`` field in
59
+ # the decoded payload (TonAPI style).
60
+ raw_amount_value = raw.get("amount") or raw.get("jetton_amount")
61
+ if raw_amount_value is None:
62
+ raw_amount_value = message.value or 0
63
+
64
+ try:
65
+ raw_amount = int(raw_amount_value)
66
+ except (TypeError, ValueError):
67
+ raw_amount = 0
68
+
69
+ # Sender/recipient depend on message direction:
70
+ # - transfer (0xf8a7ea5): sender = message.source (Jetton wallet owner)
71
+ # - transfer_notification (0x7362d09c): recipient is the destination contract
72
+ sender = raw.get("sender") or message.source
73
+ recipient = raw.get("recipient") or raw.get("destination") or message.destination
74
+
75
+ comment: str | None = None
76
+ forward = raw.get("forward_payload") or raw.get("comment")
77
+ if isinstance(forward, str) and forward:
78
+ comment = forward
79
+
80
+ return JettonTransfer(
81
+ transaction_hash=transaction.hash,
82
+ sender=sender,
83
+ recipient=recipient,
84
+ amount=normalize_amount(raw_amount, decimals),
85
+ raw_amount=raw_amount,
86
+ decimals=decimals,
87
+ jetton_wallet=(
88
+ message.destination if message.op_code == OP_JETTON_TRANSFER else message.source
89
+ ),
90
+ jetton_minter=jetton_minter,
91
+ symbol=symbol,
92
+ comment=comment,
93
+ raw=dict(raw),
94
+ )
95
+
96
+
97
+ def extract_jetton_transfers(
98
+ transaction: Transaction,
99
+ *,
100
+ decimals: int = 9,
101
+ jetton_minter: str | None = None,
102
+ symbol: str | None = None,
103
+ ) -> list[JettonTransfer]:
104
+ """Extract all Jetton transfers from a transaction's messages.
105
+
106
+ Scans both the inbound message and all outbound messages.
107
+ """
108
+ transfers: list[JettonTransfer] = []
109
+
110
+ messages: list[Message] = []
111
+ if transaction.in_message is not None:
112
+ messages.append(transaction.in_message)
113
+ messages.extend(transaction.out_messages)
114
+
115
+ for msg in messages:
116
+ result = decode_jetton_transfer(
117
+ transaction,
118
+ msg,
119
+ decimals=decimals,
120
+ jetton_minter=jetton_minter,
121
+ symbol=symbol,
122
+ )
123
+ if result is not None:
124
+ transfers.append(result)
125
+
126
+ return transfers
tonflow/models.py ADDED
@@ -0,0 +1,82 @@
1
+ """Core data models returned by tonflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
10
+
11
+ RawPayload = dict[str, Any]
12
+
13
+
14
+ class TonflowModel(BaseModel):
15
+ """Base model with stable serialization defaults."""
16
+
17
+ model_config = ConfigDict(frozen=True, populate_by_name=True)
18
+
19
+
20
+ class MessageDirection(StrEnum):
21
+ """Direction of a message relative to the indexed account."""
22
+
23
+ INBOUND = "inbound"
24
+ OUTBOUND = "outbound"
25
+
26
+
27
+ class TransactionStatus(StrEnum):
28
+ """Normalized transaction execution status."""
29
+
30
+ SUCCESS = "success"
31
+ FAILED = "failed"
32
+ UNKNOWN = "unknown"
33
+
34
+
35
+ class Message(TonflowModel):
36
+ """Normalized TON message."""
37
+
38
+ source: str | None
39
+ destination: str | None
40
+ direction: MessageDirection | None = None
41
+ value: int | None = Field(default=None, ge=0)
42
+ body: str | None = None
43
+ op_code: int | None = Field(default=None, ge=0)
44
+ raw: RawPayload = Field(default_factory=dict)
45
+
46
+
47
+ class Transaction(TonflowModel):
48
+ """Normalized TON transaction."""
49
+
50
+ hash: str
51
+ account: str
52
+ logical_time: int = Field(alias="lt", ge=0)
53
+ timestamp: int | None = Field(default=None, ge=0)
54
+ status: TransactionStatus = TransactionStatus.UNKNOWN
55
+ in_message: Message | None = None
56
+ out_messages: tuple[Message, ...] = ()
57
+ total_fees: int | None = Field(default=None, ge=0)
58
+ raw: RawPayload = Field(default_factory=dict)
59
+
60
+
61
+ class JettonTransfer(TonflowModel):
62
+ """Normalized Jetton transfer event."""
63
+
64
+ transaction_hash: str
65
+ sender: str | None
66
+ recipient: str | None
67
+ amount: Decimal = Field(ge=0)
68
+ raw_amount: int = Field(ge=0)
69
+ decimals: int = Field(ge=0, le=255)
70
+ jetton_wallet: str | None = None
71
+ jetton_minter: str | None = None
72
+ symbol: str | None = None
73
+ comment: str | None = None
74
+ raw: RawPayload = Field(default_factory=dict)
75
+
76
+ @field_validator("symbol")
77
+ @classmethod
78
+ def normalize_symbol(cls, value: str | None) -> str | None:
79
+ if value is None:
80
+ return None
81
+ normalized = value.strip()
82
+ return normalized or None
tonflow/stream.py ADDED
@@ -0,0 +1,75 @@
1
+ """Polling-based streaming of new TON transactions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator
7
+
8
+ from tonflow.client import TonClient
9
+ from tonflow.models import Transaction
10
+
11
+
12
+ async def watch_address(
13
+ client: TonClient,
14
+ address: str,
15
+ *,
16
+ interval_seconds: float = 5.0,
17
+ lookback: int = 10,
18
+ ) -> AsyncIterator[Transaction]:
19
+ """Yield new transactions for *address* as they appear on-chain.
20
+
21
+ Polls ``client.get_transactions()`` every *interval_seconds* seconds.
22
+ On the first call fetches the last *lookback* transactions to establish
23
+ a baseline; subsequent polls only yield transactions with a logical time
24
+ greater than the highest seen so far, so duplicates are never emitted.
25
+
26
+ ``watch_address`` runs indefinitely. To stop it after a fixed duration or
27
+ on an external signal, wrap it with :func:`asyncio.timeout` or cancel the
28
+ enclosing task:
29
+
30
+ .. code-block:: python
31
+
32
+ import asyncio
33
+ from tonflow import TonClient, watch_address
34
+
35
+ async def main() -> None:
36
+ async with TonClient(endpoint="https://tonapi.io") as client:
37
+ # Stop automatically after 60 seconds
38
+ async with asyncio.timeout(60):
39
+ async for tx in watch_address(client, "EQ..."):
40
+ print(tx.hash)
41
+
42
+ Args:
43
+ client: A configured :class:`TonClient` instance.
44
+ address: The TON address to watch.
45
+ interval_seconds: Seconds to wait between polls.
46
+ lookback: Number of recent transactions to fetch on the first poll
47
+ (used to seed the last-seen logical time without yielding old txs).
48
+
49
+ Yields:
50
+ :class:`Transaction` objects in ascending logical-time order.
51
+ """
52
+ last_lt: int | None = None
53
+
54
+ # Seed: fetch recent transactions to set the baseline lt without yielding them.
55
+ seed = await client.get_transactions(address, limit=lookback)
56
+ if seed:
57
+ last_lt = max(tx.logical_time for tx in seed)
58
+
59
+ while True:
60
+ await asyncio.sleep(interval_seconds)
61
+
62
+ txs = await client.get_transactions(address, limit=lookback)
63
+ if not txs:
64
+ continue
65
+
66
+ new_txs = [tx for tx in txs if last_lt is None or tx.logical_time > last_lt]
67
+ if not new_txs:
68
+ continue
69
+
70
+ # Yield in ascending order (oldest first).
71
+ new_txs.sort(key=lambda tx: tx.logical_time)
72
+ for tx in new_txs:
73
+ yield tx
74
+
75
+ last_lt = max(tx.logical_time for tx in new_txs)
@@ -0,0 +1,310 @@
1
+ Metadata-Version: 2.4
2
+ Name: tonflow
3
+ Version: 0.1.0
4
+ Summary: Python toolkit for reading, decoding, normalizing, and locally caching TON blockchain data.
5
+ Project-URL: Homepage, https://github.com/kakharov/tonflow
6
+ Project-URL: Repository, https://github.com/kakharov/tonflow
7
+ Project-URL: Issues, https://github.com/kakharov/tonflow/issues
8
+ Author-email: kakharov <kaharov95@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: blockchain,indexing,jetton,ton,web3
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: pydantic>=2.7
21
+ Provides-Extra: dev
22
+ Requires-Dist: mypy>=1.10; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
24
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
25
+ Requires-Dist: pytest>=8.2; extra == 'dev'
26
+ Requires-Dist: ruff>=0.5; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # tonflow
30
+
31
+ [![CI](https://github.com/kakharov/tonflow/actions/workflows/ci.yml/badge.svg)](https://github.com/kakharov/tonflow/actions/workflows/ci.yml)
32
+ [![codecov](https://codecov.io/gh/kakharov/tonflow/branch/main/graph/badge.svg)](https://codecov.io/gh/kakharov/tonflow)
33
+ [![PyPI](https://img.shields.io/pypi/v/tonflow)](https://pypi.org/project/tonflow/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/tonflow)](https://pypi.org/project/tonflow/)
35
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
36
+
37
+ Python toolkit for reading, decoding, normalizing, and locally caching TON blockchain data.
38
+
39
+ `tonflow` is a lightweight MIT-licensed library. It does not run a hosted indexer and does not store blockchain data on your behalf — any cache lives on your own machine or infrastructure.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install tonflow
45
+ ```
46
+
47
+ Requires Python 3.12+.
48
+
49
+ ## Quickstart
50
+
51
+ ```python
52
+ import asyncio
53
+ from tonflow import TonClient
54
+
55
+ async def main() -> None:
56
+ async with TonClient(endpoint="https://tonapi.io") as client:
57
+ txs = await client.get_transactions("EQ...", limit=10)
58
+ for tx in txs:
59
+ print(tx.hash, tx.logical_time, tx.status)
60
+
61
+ asyncio.run(main())
62
+ ```
63
+
64
+ ## Recipes
65
+
66
+ ### Get Jetton transfers
67
+
68
+ ```python
69
+ transfers = await client.get_jetton_transfers(
70
+ "EQ...",
71
+ limit=20,
72
+ decimals=6,
73
+ symbol="USDT",
74
+ )
75
+ for t in transfers:
76
+ print(t.sender, "→", t.recipient, t.amount, t.symbol)
77
+ ```
78
+
79
+ ### Stream new transactions in real time
80
+
81
+ ```python
82
+ from tonflow import watch_address
83
+
84
+ async for tx in watch_address(client, "EQ...", interval_seconds=5):
85
+ print("new tx:", tx.hash, tx.logical_time)
86
+ ```
87
+
88
+ `watch_address` runs indefinitely. To stop it after a fixed duration, wrap it
89
+ with `asyncio.timeout` (Python 3.11+):
90
+
91
+ ```python
92
+ import asyncio
93
+ from tonflow import TonClient, watch_address
94
+
95
+ async def main() -> None:
96
+ async with TonClient(endpoint="https://tonapi.io") as client:
97
+ async with asyncio.timeout(60): # stop after 60 seconds
98
+ async for tx in watch_address(client, "EQ..."):
99
+ print(tx.hash)
100
+ ```
101
+
102
+ ### Cache responses locally (avoid hammering public nodes)
103
+
104
+ ```python
105
+ from tonflow import SQLiteCache, TonClient
106
+
107
+ client = TonClient(
108
+ endpoint="https://tonapi.io",
109
+ cache=SQLiteCache(".tonflow/cache.sqlite3"),
110
+ cache_ttl_seconds=60,
111
+ )
112
+ ```
113
+
114
+ ### Export to CSV or JSON
115
+
116
+ ```python
117
+ from tonflow import jetton_transfers_to_csv, transactions_to_json
118
+
119
+ json_str = transactions_to_json(txs, indent=2)
120
+ csv_str = jetton_transfers_to_csv(transfers)
121
+
122
+ with open("transfers.csv", "w") as f:
123
+ f.write(csv_str)
124
+ ```
125
+
126
+ ### Validate a TON address
127
+
128
+ ```python
129
+ from tonflow import validate_address, is_user_friendly_address, is_raw_address
130
+
131
+ validate_address("EQ...") # raises ValueError if invalid
132
+ is_user_friendly_address("EQ...") # True / False
133
+ is_raw_address("0:abcd...") # True / False
134
+ ```
135
+
136
+ ## API reference
137
+
138
+ ### `TonClient`
139
+
140
+ ```python
141
+ TonClient(
142
+ endpoint: str,
143
+ api_key: str | None = None,
144
+ timeout: float = 10.0,
145
+ cache: JSONCache | None = None,
146
+ cache_ttl_seconds: float | None = 30.0,
147
+ )
148
+ ```
149
+
150
+ | Method | Description |
151
+ |---|---|
152
+ | `get_transactions(address, limit, before_lt)` | Fetch and normalize account transactions |
153
+ | `get_jetton_transfers(address, limit, before_lt, decimals, jetton_minter, symbol)` | Fetch transactions and return only Jetton transfer events |
154
+ | `aclose()` | Close the underlying HTTP client |
155
+
156
+ Use as an async context manager (`async with`) for automatic cleanup.
157
+
158
+ ### `watch_address`
159
+
160
+ ```python
161
+ watch_address(
162
+ client: TonClient,
163
+ address: str,
164
+ interval_seconds: float = 5.0,
165
+ lookback: int = 10,
166
+ ) -> AsyncIterator[Transaction]
167
+ ```
168
+
169
+ Polls every `interval_seconds`. Seeds a baseline on the first call so existing transactions are not replayed. Yields new transactions in ascending logical-time order.
170
+
171
+ > **Note:** `watch_address` runs indefinitely. Use `asyncio.timeout()` or task cancellation to stop it.
172
+
173
+ ### Models
174
+
175
+ | Model | Key fields |
176
+ |---|---|
177
+ | `Transaction` | `hash`, `account`, `logical_time`, `timestamp`, `status`, `in_message`, `out_messages`, `total_fees` |
178
+ | `Message` | `source`, `destination`, `direction`, `value`, `body`, `op_code` |
179
+ | `JettonTransfer` | `transaction_hash`, `sender`, `recipient`, `amount`, `raw_amount`, `decimals`, `symbol`, `jetton_wallet`, `jetton_minter`, `comment` |
180
+
181
+ ### Cache backends
182
+
183
+ | Class | Storage | Best for |
184
+ |---|---|---|
185
+ | `InMemoryCache` | In-process dict | Tests, short-lived scripts |
186
+ | `SQLiteCache(path)` | SQLite file on disk | Local scripts, small services |
187
+
188
+ Both implement the `JSONCache` protocol — you can write your own backend (e.g. Redis) by implementing `get`, `set`, and `clear`.
189
+
190
+ ### Export helpers
191
+
192
+ | Function | Output |
193
+ |---|---|
194
+ | `transactions_to_json(txs, indent=None)` | JSON string |
195
+ | `transactions_to_csv(txs)` | CSV string |
196
+ | `jetton_transfers_to_json(transfers, indent=None)` | JSON string |
197
+ | `jetton_transfers_to_csv(transfers)` | CSV string |
198
+
199
+ `Decimal` amounts are serialized as strings in both formats to preserve precision.
200
+
201
+ ### Address helpers
202
+
203
+ | Function | Description |
204
+ |---|---|
205
+ | `normalize_address(addr)` | Strip whitespace, raise on empty |
206
+ | `is_user_friendly_address(addr)` | Validate EQ/UQ/kQ/0Q 48-char format |
207
+ | `is_raw_address(addr)` | Validate `workchain:64hexchars` format |
208
+ | `validate_address(addr)` | Accept either format, raise `ValueError` on invalid |
209
+
210
+ ### Exceptions
211
+
212
+ | Exception | Raised when |
213
+ |---|---|
214
+ | `TonflowAPIError` | HTTP error from upstream TON API |
215
+ | `TonflowDecodeError` | API response cannot be parsed into expected models |
216
+
217
+ ## Examples
218
+
219
+ See the [`examples/`](examples/) directory:
220
+
221
+ - [`get_transactions.py`](examples/get_transactions.py) — fetch and print recent transactions
222
+ - [`get_jetton_transfers.py`](examples/get_jetton_transfers.py) — fetch and print Jetton transfers
223
+ - [`watch_address.py`](examples/watch_address.py) — stream new transactions in real time
224
+ - [`export_to_csv.py`](examples/export_to_csv.py) — save transactions and transfers to CSV
225
+ - [`cache_with_sqlite.py`](examples/cache_with_sqlite.py) — local SQLite cache in action
226
+
227
+ ## Development
228
+
229
+ ```powershell
230
+ git clone https://github.com/kakharov/tonflow
231
+ cd tonflow
232
+ py -3.12 -m venv .venv
233
+ .venv\Scripts\Activate.ps1
234
+ pip install -e ".[dev]"
235
+ pytest
236
+ ```
237
+
238
+ Lint and type check:
239
+
240
+ ```bash
241
+ ruff check .
242
+ ruff format .
243
+ mypy src/
244
+ ```
245
+
246
+ ## Roadmap
247
+
248
+ ### `0.1.0` — current
249
+ - [x] `TonClient` with `get_transactions()` and `get_jetton_transfers()`
250
+ - [x] TEP-74 Jetton transfer decoder
251
+ - [x] `SQLiteCache` and `InMemoryCache` with TTL
252
+ - [x] `watch_address()` polling stream
253
+ - [x] Address validation (user-friendly and raw formats)
254
+ - [x] JSON and CSV export helpers
255
+
256
+ ### `0.2.0` — planned
257
+
258
+ **TonCenter adapter**
259
+
260
+ TON ecosystem has multiple API providers with different trade-offs:
261
+
262
+ | Provider | Cost | Reliability | Notes |
263
+ |---|---|---|---|
264
+ | TonAPI | Paid | High | More endpoints, better rate limits |
265
+ | TonCenter | Free | Medium | Can drop transactions under load |
266
+ | Lite Server | Free | Highest | Direct node connection, complex setup |
267
+
268
+ `0.2.0` adds a pluggable provider system so you can switch between TonAPI and TonCenter without changing your code.
269
+
270
+ **`send_and_confirm()`**
271
+
272
+ TON is fully asynchronous — sending a transaction does not mean it was executed. Unlike Ethereum, there is no immediate transaction hash to track. Transactions can silently disappear from the mempool due to:
273
+
274
+ - `valid_until` TTL expiry (transaction not included in a block in time)
275
+ - TonCenter queue drops under high load
276
+ - `seqno` race conditions when sending in parallel
277
+
278
+ `send_and_confirm()` solves this by:
279
+
280
+ 1. Sending the transaction via any configured provider
281
+ 2. Polling every few seconds until the transaction appears on-chain
282
+ 3. Automatically retrying with a fresh `seqno` if `valid_until` has passed and the transaction is gone
283
+ 4. Returning only after the transaction is confirmed in a block
284
+
285
+ ```python
286
+ result = await client.send_and_confirm(
287
+ wallet=wallet,
288
+ messages=[...],
289
+ timeout=60,
290
+ )
291
+ print(result.hash, result.logical_time)
292
+ ```
293
+
294
+ **Full `0.2.0` scope**
295
+
296
+ - [ ] TonCenter adapter (pluggable provider interface)
297
+ - [ ] `send_and_confirm()` with polling, TTL tracking and automatic retry
298
+ - [ ] Websocket stream support (TonAPI / TonCenter)
299
+ - [ ] Jetton burn and mint event decoding
300
+ - [ ] Redis cache adapter
301
+
302
+ ### `0.3.0` — planned
303
+ - [ ] NFT transfer event decoding
304
+ - [ ] CLI: `tonflow scan <address>`
305
+ - [ ] Postgres export helper
306
+ - [ ] Backfill utility for historical data
307
+
308
+ ## License
309
+
310
+ MIT
@@ -0,0 +1,13 @@
1
+ tonflow/__init__.py,sha256=UD1Jj3Iiv3XiK8KMW4HmzclG26PC_U5Z7lIkWF0KPmY,1470
2
+ tonflow/addresses.py,sha256=Rn43EgijoTc1-tg38H57fcRfYd-LFijTUVuh9t8OZaY,2301
3
+ tonflow/cache.py,sha256=KmEfV7jYK2hEWw8T5Yy28MdNrbtVNK1_lvb2owGavhA,3898
4
+ tonflow/client.py,sha256=3xbq8Sc1OeHmKbvjJXVOW9u4OWtf7MJntk7LBr7v5p0,11481
5
+ tonflow/exceptions.py,sha256=clPpD2IB_agvtDr0bKy0DXeLNho6EyDztDS9cmTvTS8,590
6
+ tonflow/export.py,sha256=tt7_ETIQ9mYwiEFQ3A6lo8_Nu36SXH4UHTef-h8w9-8,3024
7
+ tonflow/jettons.py,sha256=Yaf6r3AVL5lKsWBc4zw9VsoP1qoHo091_Ssw4qeVGLY,4051
8
+ tonflow/models.py,sha256=WtO7bJvMauH5W7Kdez_IKDSkjUssR7e7ypfSlAxNhLQ,2198
9
+ tonflow/stream.py,sha256=sVaZQPe3qrMBxZxpyhrsNa7ZYsNMfd9ImChlHyDkSVA,2545
10
+ tonflow-0.1.0.dist-info/METADATA,sha256=C_lW1JRvMpRM5ad_rmVwIn-KZMgfPo-kLSoz1oZQPZ4,9715
11
+ tonflow-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ tonflow-0.1.0.dist-info/licenses/LICENSE,sha256=YblrJ3l-bZ36jPQrFh3z_ldlBI3BaFdtHPYFYL0iJM8,1065
13
+ tonflow-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kakharov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.