tonutils 2.0.1b5__py3-none-any.whl → 2.0.1b7__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.
Files changed (58) hide show
  1. tonutils/__meta__.py +1 -1
  2. tonutils/clients/__init__.py +10 -10
  3. tonutils/clients/adnl/balancer.py +135 -361
  4. tonutils/clients/adnl/client.py +35 -208
  5. tonutils/clients/adnl/mixin.py +268 -0
  6. tonutils/clients/adnl/provider/config.py +22 -7
  7. tonutils/clients/adnl/provider/provider.py +61 -16
  8. tonutils/clients/adnl/provider/transport.py +13 -4
  9. tonutils/clients/adnl/provider/workers/pinger.py +1 -1
  10. tonutils/clients/adnl/utils.py +5 -5
  11. tonutils/clients/base.py +61 -95
  12. tonutils/clients/http/__init__.py +11 -11
  13. tonutils/clients/http/balancer.py +103 -100
  14. tonutils/clients/http/clients/__init__.py +10 -10
  15. tonutils/clients/http/clients/chainstack.py +3 -3
  16. tonutils/clients/http/clients/quicknode.py +2 -3
  17. tonutils/clients/http/clients/tatum.py +4 -3
  18. tonutils/clients/http/clients/tonapi.py +20 -33
  19. tonutils/clients/http/clients/toncenter.py +64 -55
  20. tonutils/clients/http/{providers → provider}/__init__.py +4 -1
  21. tonutils/clients/http/{providers → provider}/base.py +140 -61
  22. tonutils/clients/http/{providers/toncenter → provider}/models.py +44 -2
  23. tonutils/clients/http/{providers/tonapi/provider.py → provider/tonapi.py} +8 -13
  24. tonutils/clients/http/{providers/toncenter/provider.py → provider/toncenter.py} +25 -21
  25. tonutils/clients/limiter.py +61 -59
  26. tonutils/clients/protocol.py +8 -8
  27. tonutils/contracts/base.py +32 -32
  28. tonutils/contracts/protocol.py +9 -9
  29. tonutils/contracts/wallet/base.py +7 -8
  30. tonutils/contracts/wallet/messages.py +4 -8
  31. tonutils/contracts/wallet/versions/v5.py +2 -2
  32. tonutils/exceptions.py +29 -13
  33. tonutils/tonconnect/bridge/__init__.py +0 -0
  34. tonutils/tonconnect/events.py +0 -0
  35. tonutils/tonconnect/models/__init__.py +0 -0
  36. tonutils/tonconnect/storage.py +0 -0
  37. tonutils/tonconnect/tonconnect.py +0 -0
  38. tonutils/tools/block_scanner/__init__.py +2 -5
  39. tonutils/tools/block_scanner/events.py +48 -7
  40. tonutils/tools/block_scanner/scanner.py +316 -222
  41. tonutils/tools/block_scanner/storage.py +11 -0
  42. tonutils/tools/status_monitor/monitor.py +6 -6
  43. tonutils/types.py +2 -2
  44. tonutils/utils.py +0 -48
  45. {tonutils-2.0.1b5.dist-info → tonutils-2.0.1b7.dist-info}/METADATA +3 -18
  46. {tonutils-2.0.1b5.dist-info → tonutils-2.0.1b7.dist-info}/RECORD +50 -51
  47. {tonutils-2.0.1b5.dist-info → tonutils-2.0.1b7.dist-info}/WHEEL +1 -1
  48. tonutils/clients/http/providers/response.py +0 -85
  49. tonutils/clients/http/providers/tonapi/__init__.py +0 -3
  50. tonutils/clients/http/providers/tonapi/models.py +0 -47
  51. tonutils/clients/http/providers/toncenter/__init__.py +0 -3
  52. tonutils/tools/block_scanner/annotations.py +0 -23
  53. tonutils/tools/block_scanner/dispatcher.py +0 -141
  54. tonutils/tools/block_scanner/traversal.py +0 -97
  55. tonutils/tools/block_scanner/where.py +0 -53
  56. {tonutils-2.0.1b5.dist-info → tonutils-2.0.1b7.dist-info}/entry_points.txt +0 -0
  57. {tonutils-2.0.1b5.dist-info → tonutils-2.0.1b7.dist-info}/licenses/LICENSE +0 -0
  58. {tonutils-2.0.1b5.dist-info → tonutils-2.0.1b7.dist-info}/top_level.txt +0 -0
@@ -1,138 +1,188 @@
1
1
  import asyncio
2
2
  import typing as t
3
- from dataclasses import dataclass
4
3
 
5
- from pytoniq_core import Transaction
6
4
  from pytoniq_core.tl import BlockIdExt
5
+ from pytoniq_core.tlb.block import ExtBlkRef
7
6
 
8
7
  from tonutils.clients import LiteBalancer, LiteClient
9
- from tonutils.tools.block_scanner.annotations import (
10
- BlockWhere,
11
- Decorator,
12
- Handler,
13
- TransactionWhere,
14
- TransactionsWhere,
15
- )
16
- from tonutils.tools.block_scanner.dispatcher import EventDispatcher
17
8
  from tonutils.tools.block_scanner.events import (
18
9
  BlockEvent,
19
- TransactionEvent,
10
+ ErrorEvent,
20
11
  TransactionsEvent,
21
12
  )
22
- from tonutils.tools.block_scanner.traversal import ShardTraversal
23
- from tonutils.types import WorkchainID, MASTERCHAIN_SHARD
24
-
13
+ from tonutils.tools.block_scanner.storage import BlockScannerStorageProtocol
14
+ from tonutils.types import MASTERCHAIN_SHARD, WorkchainID
25
15
 
26
- @dataclass(slots=True)
27
- class _ScanState:
28
- """Internal scanner state per masterchain block."""
16
+ ShardKey = t.Tuple[int, int]
17
+ SeenShardSeqno = t.Dict[ShardKey, int]
18
+ BlockQueue = asyncio.Queue[BlockIdExt]
29
19
 
30
- mc_block: BlockIdExt
31
- shards_seqno: t.Dict[t.Tuple[int, int], int]
20
+ OnError = t.Callable[[ErrorEvent], t.Awaitable[None]]
21
+ OnBlock = t.Callable[[BlockEvent], t.Awaitable[None]]
22
+ OnTransactions = t.Callable[[TransactionsEvent], t.Awaitable[None]]
32
23
 
33
24
 
34
25
  class BlockScanner:
35
- """Asynchronous scanner for TON blockchain."""
26
+ """
27
+ Asynchronous queue-based TON block scanner.
28
+
29
+ Discovers shard blocks by following masterchain shard tips, emits events for each
30
+ shard block, and optionally fetches transactions for shard blocks.
31
+
32
+ Handlers can be passed via constructor or set later via decorators:
33
+ - on_error: receives ErrorEvent
34
+ - on_block: receives BlockEvent
35
+ - on_transactions: receives TransactionsEvent
36
+ """
36
37
 
37
38
  def __init__(
38
39
  self,
39
- *,
40
40
  client: t.Union[LiteBalancer, LiteClient],
41
+ *,
42
+ on_error: t.Optional[OnError] = None,
43
+ on_block: t.Optional[OnBlock] = None,
44
+ on_transactions: t.Optional[OnTransactions] = None,
45
+ storage: t.Optional[BlockScannerStorageProtocol] = None,
41
46
  poll_interval: float = 0.1,
42
- include_transactions: bool = True,
43
- max_concurrency: int = 1000,
44
47
  **context: t.Any,
45
48
  ) -> None:
46
49
  """
47
- Initialize a BlockScanner.
48
-
49
- :param client: LiteClient or LiteBalancer instance for blockchain access.
50
- :param poll_interval: Interval in seconds to poll for new masterchain blocks.
51
- :param include_transactions: If True, emit TransactionEvent and TransactionsEvent.
52
- :param max_concurrency: Maximum number of concurrent event handler tasks.
53
- :param context: Additional key/value data passed to all emitted events.
50
+ Initialize scanner.
51
+
52
+ :param client: Lite client/balancer.
53
+ :param on_error: Called on internal errors and handler failures.
54
+ :param on_block: Called for each discovered shard block.
55
+ :param on_transactions: Called for shard blocks with fetched transactions.
56
+ :param storage: Progress storage (masterchain seqno).
57
+ :param poll_interval: Poll delay while waiting for next masterchain block.
58
+ :param context: Shared context passed to all events.
54
59
  """
55
60
  self._client = client
56
- self._context = dict(context)
61
+ self._on_error = on_error
62
+ self._on_block = on_block
63
+ self._on_transactions = on_transactions
64
+ self._storage = storage
57
65
  self._poll_interval = poll_interval
58
- self._include_transactions = include_transactions
59
-
60
- self._traversal = ShardTraversal()
61
- self._dispatcher = EventDispatcher(max_concurrency)
66
+ self._context = dict(context)
62
67
 
68
+ self._pending_blocks: BlockQueue = asyncio.Queue()
63
69
  self._stop_event = asyncio.Event()
64
70
  self._running = False
65
71
 
66
- @t.overload
67
- def register(
68
- self,
69
- event_type: t.Type[BlockEvent],
70
- handler: Handler[BlockEvent],
71
- *,
72
- where: t.Optional[BlockWhere] = None,
73
- ) -> None: ...
72
+ @staticmethod
73
+ def _shard_key(blk: BlockIdExt) -> ShardKey:
74
+ """Return shard key as (workchain, shard)."""
75
+ return blk.workchain, blk.shard
76
+
77
+ @staticmethod
78
+ def _overflow_i64(x: int) -> int:
79
+ """Wrap integer to signed 64-bit range."""
80
+ return (x + 2**63) % 2**64 - 2**63
81
+
82
+ @staticmethod
83
+ def _lowbit64(x: int) -> int:
84
+ """Return lowest set bit (64-bit shard math helper)."""
85
+ return x & (~x + 1)
86
+
87
+ def _child_shard(self, shard: int, *, left: bool) -> int:
88
+ """Return left/right child shard id for split shards."""
89
+ step = self._lowbit64(shard) >> 1
90
+ return self._overflow_i64(shard - step if left else shard + step)
91
+
92
+ def _parent_shard(self, shard: int) -> int:
93
+ """Return parent shard id for merged shards."""
94
+ step = self._lowbit64(shard)
95
+ return self._overflow_i64((shard - step) | (step << 1))
96
+
97
+ @property
98
+ def last_mc_block(self) -> BlockIdExt:
99
+ """Return last known masterchain block from provider cache."""
100
+ return self._client.provider.last_mc_block
74
101
 
75
- @t.overload
76
- def register(
77
- self,
78
- event_type: t.Type[TransactionEvent],
79
- handler: Handler[TransactionEvent],
80
- *,
81
- where: t.Optional[TransactionWhere] = None,
82
- ) -> None: ...
102
+ def on_error(self, fn: t.Optional[OnError] = None) -> t.Any:
103
+ """Decorator to set error handler."""
83
104
 
84
- @t.overload
85
- def register(
86
- self,
87
- event_type: t.Type[TransactionsEvent],
88
- handler: Handler[TransactionsEvent],
89
- *,
90
- where: t.Optional[TransactionsWhere] = None,
91
- ) -> None: ...
105
+ def decorator(handler: OnError) -> OnError:
106
+ self._on_error = handler
107
+ return handler
108
+
109
+ return decorator if fn is None else decorator(fn)
110
+
111
+ def on_block(self, fn: t.Optional[OnBlock] = None) -> t.Any:
112
+ """Decorator to set block handler."""
113
+
114
+ def decorator(handler: OnBlock) -> OnBlock:
115
+ self._on_block = handler
116
+ return handler
117
+
118
+ return decorator if fn is None else decorator(fn)
92
119
 
93
- def register(
120
+ def on_transactions(self, fn: t.Optional[OnTransactions] = None) -> t.Any:
121
+ """Decorator to set transactions handler."""
122
+
123
+ def decorator(handler: OnTransactions) -> OnTransactions:
124
+ self._on_transactions = handler
125
+ return handler
126
+
127
+ return decorator if fn is None else decorator(fn)
128
+
129
+ async def _call_error_handler(
94
130
  self,
95
- event_type: t.Any,
96
- handler: t.Any,
131
+ error: BaseException,
132
+ mc_block: BlockIdExt,
97
133
  *,
98
- where: t.Any = None,
134
+ event: t.Any = None,
135
+ handler: t.Any = None,
136
+ block: t.Optional[BlockIdExt] = None,
99
137
  ) -> None:
100
- """Register a handler for an event type with optional filter."""
101
- self._dispatcher.register(event_type, handler, where=where)
102
-
103
- def on_block(
104
- self,
105
- where: t.Optional[BlockWhere] = None,
106
- ) -> Decorator[BlockEvent]:
107
- """Decorator for block event handlers."""
108
- return self._dispatcher.on(BlockEvent, where=where)
138
+ """Call error handler with ErrorEvent. Never raises."""
139
+ if self._on_error is None:
140
+ return
109
141
 
110
- def on_transaction(
111
- self,
112
- where: t.Optional[TransactionWhere] = None,
113
- ) -> Decorator[TransactionEvent]:
114
- """Decorator for transaction event handlers."""
115
- return self._dispatcher.on(TransactionEvent, where=where)
142
+ try:
143
+ await self._on_error(
144
+ ErrorEvent(
145
+ client=self._client,
146
+ mc_block=mc_block,
147
+ context=self._context,
148
+ error=error,
149
+ event=event,
150
+ handler=handler,
151
+ block=block,
152
+ )
153
+ )
154
+ except asyncio.CancelledError:
155
+ raise
156
+ except (BaseException,):
157
+ return
116
158
 
117
- def on_transactions(
118
- self,
119
- where: t.Optional[TransactionsWhere] = None,
120
- ) -> Decorator[TransactionsEvent]:
121
- """Decorator for batch transaction event handlers."""
122
- return self._dispatcher.on(TransactionsEvent, where=where)
159
+ async def _call_handler(self, handler: t.Any, event: t.Any) -> None:
160
+ """Call handler(event). Route failures to on_error."""
161
+ if handler is None:
162
+ return
123
163
 
124
- def _get_last_mc_block(self) -> BlockIdExt:
125
- """Return last masterchain block."""
126
- return self._client.provider.last_mc_block
164
+ try:
165
+ await handler(event)
166
+ except asyncio.CancelledError:
167
+ raise
168
+ except BaseException as error:
169
+ await self._call_error_handler(
170
+ error,
171
+ event.mc_block,
172
+ event=event,
173
+ handler=handler,
174
+ block=event.block,
175
+ )
127
176
 
128
177
  async def _lookup_mc_block(
129
178
  self,
179
+ *,
130
180
  seqno: t.Optional[int] = None,
131
181
  lt: t.Optional[int] = None,
132
182
  utime: t.Optional[int] = None,
133
183
  ) -> BlockIdExt:
134
- """Lookup masterchain block by seqno, lt, or utime."""
135
- mc_block, _info = await self._client.lookup_block(
184
+ """Lookup masterchain block by seqno/lt/utime."""
185
+ mc_block, _ = await self._client.lookup_block(
136
186
  workchain=WorkchainID.MASTERCHAIN,
137
187
  shard=MASTERCHAIN_SHARD,
138
188
  seqno=seqno,
@@ -141,173 +191,217 @@ class BlockScanner:
141
191
  )
142
192
  return mc_block
143
193
 
144
- async def _init_state(
145
- self,
146
- seqno: t.Optional[int] = None,
147
- lt: t.Optional[int] = None,
148
- utime: t.Optional[int] = None,
149
- ) -> _ScanState:
150
- """Initialize scanning state."""
151
- if seqno is None and lt is None and utime is None:
152
- mc_block = self._get_last_mc_block()
153
- else:
154
- mc_block = await self._lookup_mc_block(seqno=seqno, lt=lt, utime=utime)
194
+ async def _wait_next_mc_block(self, mc_block: BlockIdExt) -> BlockIdExt:
195
+ """Wait until next masterchain block becomes available."""
196
+ next_mc_seqno = mc_block.seqno + 1
155
197
 
156
- if mc_block.seqno > 0:
157
- prev_mc = await self._lookup_mc_block(seqno=mc_block.seqno - 1)
158
- else:
159
- prev_mc = mc_block
198
+ while not self._stop_event.is_set():
199
+ last_mc_block = self.last_mc_block
200
+ if next_mc_seqno <= last_mc_block.seqno:
201
+ if next_mc_seqno == last_mc_block.seqno:
202
+ return last_mc_block
203
+ return await self._lookup_mc_block(seqno=next_mc_seqno)
160
204
 
161
- shards_seqno: t.Dict[t.Tuple[int, int], int] = {}
162
- for shard in await self._client.get_all_shards_info(prev_mc):
163
- shards_seqno[self._traversal.shard_key(shard)] = shard.seqno
205
+ await asyncio.sleep(self._poll_interval)
164
206
 
165
- return _ScanState(mc_block=mc_block, shards_seqno=shards_seqno)
207
+ return mc_block
166
208
 
167
- def _ensure_running(self) -> None:
168
- """Raise CancelledError if scanner was stopped."""
169
- if self._stop_event.is_set():
170
- raise asyncio.CancelledError("Block scanner stopped")
209
+ async def _get_seen_shard_seqno(self, mc_block: BlockIdExt) -> SeenShardSeqno:
210
+ """Build map of last processed shard seqno from previous masterchain block."""
211
+ seen_shard_seqno: SeenShardSeqno = {}
212
+ if mc_block.seqno <= 0:
213
+ return seen_shard_seqno
171
214
 
172
- async def _collect_blocks(
173
- self,
174
- mc_block: BlockIdExt,
175
- shards_seqno: t.Dict[t.Tuple[int, int], int],
176
- ) -> t.List[BlockIdExt]:
177
- """Collect all unseen shard blocks for a masterchain block."""
178
- shards = await self._client.get_all_shards_info(mc_block)
179
-
180
- blocks: t.List[BlockIdExt] = []
181
- for shard_tip in shards:
182
- blocks.extend(
183
- await self._traversal.walk_unseen(
184
- root=shard_tip,
185
- seen_seqno=shards_seqno,
186
- get_header=self._client.get_block_header,
187
- )
188
- )
189
- # Update seen_seqno after collecting blocks for this shard
190
- shards_seqno[self._traversal.shard_key(shard_tip)] = shard_tip.seqno
215
+ prev_mc_block = await self._lookup_mc_block(seqno=mc_block.seqno - 1)
216
+ for shard_tip in await self._client.get_all_shards_info(prev_mc_block):
217
+ seen_shard_seqno[self._shard_key(shard_tip)] = shard_tip.seqno
191
218
 
192
- return blocks
219
+ return seen_shard_seqno
193
220
 
194
- def _emit_block(self, mc_block: BlockIdExt, block: BlockIdExt) -> None:
195
- """Emit block event."""
196
- self._dispatcher.emit(
197
- BlockEvent(
198
- mc_block=mc_block,
221
+ async def _process_pending_blocks(self, mc_block: BlockIdExt) -> None:
222
+ """Process queued shard blocks and emit events."""
223
+ while not self._stop_event.is_set():
224
+ try:
225
+ shard_block = self._pending_blocks.get_nowait()
226
+ except asyncio.QueueEmpty:
227
+ return
228
+
229
+ block_event = BlockEvent(
199
230
  client=self._client,
231
+ mc_block=mc_block,
232
+ block=shard_block,
200
233
  context=self._context,
201
- block=block,
202
234
  )
203
- )
235
+ await self._call_handler(self._on_block, block_event)
236
+
237
+ if self._on_transactions is None:
238
+ continue
239
+
240
+ get_block_transactions = self._client.get_block_transactions
241
+ try:
242
+ transactions = await get_block_transactions(shard_block)
243
+ except asyncio.CancelledError:
244
+ raise
245
+ except BaseException as error:
246
+ await self._call_error_handler(
247
+ error,
248
+ mc_block,
249
+ event=block_event,
250
+ handler=get_block_transactions,
251
+ block=shard_block,
252
+ )
253
+ transactions = []
204
254
 
205
- def _emit_transactions(
206
- self,
207
- mc_block: BlockIdExt,
208
- block: BlockIdExt,
209
- transactions: t.List[Transaction],
210
- ) -> None:
211
- """Emit batch transactions event."""
212
- self._dispatcher.emit(
213
- TransactionsEvent(
214
- mc_block=mc_block,
255
+ transactions_event = TransactionsEvent(
215
256
  client=self._client,
216
- context=self._context,
217
- block=block,
257
+ mc_block=mc_block,
258
+ block=shard_block,
218
259
  transactions=transactions,
260
+ context=self._context,
219
261
  )
220
- )
262
+ await self._call_handler(self._on_transactions, transactions_event)
221
263
 
222
- def _emit_transaction(
264
+ async def _enqueue_missing_blocks(
223
265
  self,
224
- mc_block: BlockIdExt,
225
- block: BlockIdExt,
226
- transaction: Transaction,
266
+ shard_tip: BlockIdExt,
267
+ seen_seqno: SeenShardSeqno,
227
268
  ) -> None:
228
- """Emit single transaction event."""
229
- self._dispatcher.emit(
230
- TransactionEvent(
231
- mc_block=mc_block,
232
- client=self._client,
233
- context=self._context,
234
- block=block,
235
- transaction=transaction,
269
+ """Enqueue unseen shard blocks in order (oldest -> newest)."""
270
+ shard_id = self._shard_key(shard_tip)
271
+ if seen_seqno.get(shard_id, -1) >= shard_tip.seqno:
272
+ return
273
+
274
+ _, header = await self._client.get_block_header(shard_tip)
275
+ info = header.info
276
+ prev_ref = info.prev_ref
277
+
278
+ if prev_ref.type_ == "prev_blk_info":
279
+ prev: ExtBlkRef = prev_ref.prev
280
+ prev_shard = (
281
+ self._parent_shard(shard_tip.shard)
282
+ if info.after_split
283
+ else shard_tip.shard
236
284
  )
237
- )
238
285
 
239
- async def _handle_block(
240
- self,
241
- mc_block: BlockIdExt,
242
- block: BlockIdExt,
243
- ) -> None:
244
- """Process shard block and emit events for block + transactions."""
245
- self._ensure_running()
246
- self._emit_block(mc_block, block)
286
+ await self._enqueue_missing_blocks(
287
+ shard_tip=BlockIdExt(
288
+ workchain=shard_tip.workchain,
289
+ shard=prev_shard,
290
+ seqno=prev.seqno,
291
+ root_hash=prev.root_hash,
292
+ file_hash=prev.file_hash,
293
+ ),
294
+ seen_seqno=seen_seqno,
295
+ )
296
+ else:
297
+ prev1, prev2 = prev_ref.prev1, prev_ref.prev2
298
+
299
+ await self._enqueue_missing_blocks(
300
+ shard_tip=BlockIdExt(
301
+ workchain=shard_tip.workchain,
302
+ shard=self._child_shard(shard_tip.shard, left=True),
303
+ seqno=prev1.seqno,
304
+ root_hash=prev1.root_hash,
305
+ file_hash=prev1.file_hash,
306
+ ),
307
+ seen_seqno=seen_seqno,
308
+ )
309
+ await self._enqueue_missing_blocks(
310
+ shard_tip=BlockIdExt(
311
+ workchain=shard_tip.workchain,
312
+ shard=self._child_shard(shard_tip.shard, left=False),
313
+ seqno=prev2.seqno,
314
+ root_hash=prev2.root_hash,
315
+ file_hash=prev2.file_hash,
316
+ ),
317
+ seen_seqno=seen_seqno,
318
+ )
247
319
 
248
- if not self._include_transactions:
249
- return
320
+ await self._pending_blocks.put(shard_tip)
321
+
322
+ async def _run(self, mc_block: BlockIdExt) -> None:
323
+ """Run scanning loop from provided masterchain block."""
324
+ if self._running:
325
+ raise RuntimeError("BlockScanner already running")
326
+
327
+ self._running = True
328
+ self._stop_event.clear()
250
329
 
251
- transactions = await self._client.get_block_transactions_ext(block)
252
- self._emit_transactions(mc_block, block, transactions)
330
+ try:
331
+ seen_shard_seqno = await self._get_seen_shard_seqno(mc_block)
253
332
 
254
- for transaction in transactions:
255
- self._ensure_running()
256
- self._emit_transaction(mc_block, block, transaction)
333
+ while not self._stop_event.is_set():
334
+ for shard_tip in await self._client.get_all_shards_info(mc_block):
335
+ await self._enqueue_missing_blocks(shard_tip, seen_shard_seqno)
336
+ seen_shard_seqno[self._shard_key(shard_tip)] = shard_tip.seqno
257
337
 
258
- async def _wait_next_mc_block(self, current: BlockIdExt) -> BlockIdExt:
259
- """Wait for next masterchain block, polling until available."""
260
- next_seqno = current.seqno + 1
338
+ await self._process_pending_blocks(mc_block)
261
339
 
262
- while True:
263
- self._ensure_running()
264
- last_mc_block = self._get_last_mc_block()
340
+ if self._storage is not None:
341
+ await self._storage.set_mc_seqno(mc_block.seqno)
342
+ mc_block = await self._wait_next_mc_block(mc_block)
343
+ finally:
344
+ self._running = False
345
+ self._stop_event.set()
265
346
 
266
- if next_seqno <= last_mc_block.seqno:
267
- if next_seqno == last_mc_block.seqno:
268
- return last_mc_block
269
- return await self._lookup_mc_block(seqno=next_seqno)
347
+ async def resume(self) -> None:
348
+ """Resume from storage."""
349
+ if self._storage is None:
350
+ raise RuntimeError("Storage is not configured")
270
351
 
271
- await asyncio.sleep(self._poll_interval)
352
+ saved_seqno = await self._storage.get_mc_seqno()
353
+ if saved_seqno is None or saved_seqno < 0:
354
+ raise RuntimeError("No masterchain seqno in storage")
355
+
356
+ last_mc_block = self.last_mc_block
357
+ if saved_seqno > last_mc_block.seqno:
358
+ raise RuntimeError("Storage masterchain seqno is ahead of network")
272
359
 
273
- async def start(
360
+ if saved_seqno >= last_mc_block.seqno:
361
+ mc_block = await self._wait_next_mc_block(last_mc_block)
362
+ else:
363
+ next_seqno = saved_seqno + 1
364
+ mc_block = (
365
+ last_mc_block
366
+ if next_seqno >= last_mc_block.seqno
367
+ else await self._lookup_mc_block(seqno=next_seqno)
368
+ )
369
+
370
+ await self._run(mc_block)
371
+
372
+ async def start_from(
274
373
  self,
275
- from_seqno: t.Optional[int] = None,
276
- from_lt: t.Optional[int] = None,
277
- from_utime: t.Optional[int] = None,
374
+ *,
375
+ seqno: t.Optional[int] = None,
376
+ utime: t.Optional[int] = None,
377
+ lt: t.Optional[int] = None,
278
378
  ) -> None:
279
379
  """
280
- Start scanning from the specified point.
380
+ Start scanning from an explicit masterchain point.
281
381
 
282
- :param from_seqno: start from specific masterchain sequence number.
283
- :param from_lt: start from specific logical time (LT) of a block.
284
- :param from_utime: start from specific Unix timestamp.
382
+ :param seqno: Masterchain seqno.
383
+ :param utime: Unix time (resolved to masterchain block).
384
+ :param lt: Logical time (resolved to masterchain block).
285
385
  """
286
- if self._running:
287
- raise RuntimeError("BlockScanner is already running")
288
-
289
- self._running = True
290
- self._stop_event.clear()
386
+ provided = sum(v is not None for v in (seqno, utime, lt))
387
+ if provided != 1:
388
+ raise ValueError("Provide exactly one of seqno, utime, lt")
389
+
390
+ if seqno is not None:
391
+ mc_block = await self._lookup_mc_block(seqno=seqno)
392
+ elif utime is not None:
393
+ mc_block = await self._lookup_mc_block(utime=utime)
394
+ elif lt is not None:
395
+ mc_block = await self._lookup_mc_block(lt=lt)
396
+ else:
397
+ raise AssertionError("unreachable")
291
398
 
292
- state = await self._init_state(
293
- seqno=from_seqno,
294
- lt=from_lt,
295
- utime=from_utime,
296
- )
399
+ await self._run(mc_block)
297
400
 
298
- try:
299
- while not self._stop_event.is_set():
300
- blocks = await self._collect_blocks(
301
- mc_block=state.mc_block,
302
- shards_seqno=state.shards_seqno,
303
- )
304
- for block in blocks:
305
- await self._handle_block(state.mc_block, block)
306
- state.mc_block = await self._wait_next_mc_block(state.mc_block)
307
- finally:
308
- await self._dispatcher.aclose()
309
- self._running = False
401
+ async def start(self) -> None:
402
+ """Start from the current last masterchain block."""
403
+ await self._run(self.last_mc_block)
310
404
 
311
405
  async def stop(self) -> None:
312
- """Stop scanning."""
406
+ """Request scanner stop."""
313
407
  self._stop_event.set()
@@ -0,0 +1,11 @@
1
+ import typing as t
2
+
3
+
4
+ class BlockScannerStorageProtocol(t.Protocol):
5
+ """Store BlockScanner progress (masterchain seqno)."""
6
+
7
+ async def get_mc_seqno(self) -> t.Optional[int]:
8
+ """Return last processed masterchain seqno (or None)."""
9
+
10
+ async def set_mc_seqno(self, seqno: int) -> None:
11
+ """Persist last processed masterchain seqno."""