fastweb3-objects 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.
@@ -0,0 +1,205 @@
1
+ """Internal RPC response cache middleware used by Chain Web3 instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ from .db import CacheDB, get_cache_db
11
+
12
+ RPC_CACHE_SCHEMA = """
13
+ CREATE TABLE IF NOT EXISTS rpc_cache (
14
+ chain_id INTEGER NOT NULL,
15
+ method TEXT NOT NULL,
16
+ params_json TEXT NOT NULL,
17
+ result_json TEXT NOT NULL,
18
+ created_at INTEGER NOT NULL,
19
+ updated_at INTEGER NOT NULL,
20
+ PRIMARY KEY (chain_id, method, params_json)
21
+ );
22
+ """
23
+
24
+ _READ = object()
25
+ CacheRule = Callable[[Any, Any], Any | None]
26
+
27
+ ERC20_METADATA_SELECTORS = {
28
+ "0x06fdde03", # name()
29
+ "0x95d89b41", # symbol()
30
+ "0x313ce567", # decimals()
31
+ }
32
+
33
+
34
+ def _json_dumps(value: Any) -> str:
35
+ return json.dumps(value, sort_keys=True, separators=(",", ":"))
36
+
37
+
38
+ def _eth_get_code_cache_params(call, result: Any = _READ) -> Any | None:
39
+ params = getattr(call, "params", None)
40
+ if not isinstance(params, (list, tuple)) or len(params) < 2:
41
+ return None
42
+
43
+ address, block = params[:2]
44
+ if block != "latest":
45
+ return None
46
+
47
+ if result is not _READ and result == "0x":
48
+ return None
49
+
50
+ return [address.lower(), "latest"]
51
+
52
+
53
+ def _eth_call_cache_params(call, result: Any = _READ) -> Any | None:
54
+ params = getattr(call, "params", None)
55
+ if not isinstance(params, (list, tuple)) or len(params) < 2:
56
+ return None
57
+
58
+ tx, block = params[:2]
59
+ if block != "latest":
60
+ return None
61
+
62
+ if not isinstance(tx, dict):
63
+ return None
64
+
65
+ to = tx.get("to")
66
+ data = tx.get("data") or tx.get("input")
67
+
68
+ if not isinstance(to, str) or not isinstance(data, str):
69
+ return None
70
+
71
+ data = data.lower()
72
+ if data not in ERC20_METADATA_SELECTORS:
73
+ return None
74
+
75
+ if isinstance(result, Exception):
76
+ return None
77
+
78
+ return [to.lower(), data, "latest"]
79
+
80
+
81
+ CACHE_RULES: dict[str, CacheRule] = {
82
+ "eth_getCode": _eth_get_code_cache_params,
83
+ "eth_call": _eth_call_cache_params,
84
+ }
85
+
86
+
87
+ def cache_params(call, result: Any = _READ) -> Any | None:
88
+ """Return normalized cache params for a call, if it is cacheable."""
89
+ rule = CACHE_RULES.get(call.method)
90
+ if rule is None:
91
+ return None
92
+ return rule(call, result)
93
+
94
+
95
+ class RpcCache:
96
+ """Read and write raw RPC cache entries."""
97
+
98
+ def __init__(self, db: CacheDB | None = None) -> None:
99
+ """Initialize the RPC cache.
100
+
101
+ Args:
102
+ db: Cache database. If omitted, the process-global cache is used.
103
+ """
104
+ if db is None:
105
+ db = get_cache_db()
106
+ self.db = db
107
+
108
+ def get(self, chain_id: int, method: str, params: Any) -> Any | None:
109
+ """Return a cached RPC result, if present."""
110
+ params_json = _json_dumps(params)
111
+ row = self.db.execute(
112
+ """
113
+ SELECT result_json FROM rpc_cache
114
+ WHERE chain_id = ? AND method = ? AND params_json = ?
115
+ """,
116
+ (int(chain_id), method, params_json),
117
+ ).fetchone()
118
+
119
+ if row is None:
120
+ return None
121
+ return json.loads(row["result_json"])
122
+
123
+ def set(self, chain_id: int, method: str, params: Any, result: Any) -> None:
124
+ """Store an RPC result."""
125
+ now = int(time.time())
126
+ params_json = _json_dumps(params)
127
+ result_json = _json_dumps(result)
128
+ self.db.execute(
129
+ """
130
+ INSERT INTO rpc_cache (
131
+ chain_id, method, params_json, result_json, created_at, updated_at
132
+ ) VALUES (?, ?, ?, ?, ?, ?)
133
+ ON CONFLICT(chain_id, method, params_json) DO UPDATE SET
134
+ result_json = excluded.result_json,
135
+ updated_at = excluded.updated_at
136
+ """,
137
+ (
138
+ int(chain_id),
139
+ method,
140
+ params_json,
141
+ result_json,
142
+ now,
143
+ now,
144
+ ),
145
+ )
146
+ self.db.commit()
147
+
148
+
149
+ class RpcCacheMiddleware:
150
+ """fastweb3 middleware that caches selected RPC responses."""
151
+
152
+ def __init__(self, chain_id: int, db: CacheDB | None = None) -> None:
153
+ """Initialize the middleware.
154
+
155
+ Args:
156
+ chain_id: Chain ID to use in cache keys.
157
+ db: Cache database. If omitted, the process-global cache is used.
158
+ """
159
+ self.chain_id = int(chain_id)
160
+ self.cache = RpcCache(db)
161
+
162
+ def before_request(self, ctx, calls):
163
+ """Remove cached calls from the outbound request list."""
164
+ cached_results = {}
165
+ miss_indexes = []
166
+ miss_calls = []
167
+
168
+ for idx, call in enumerate(calls):
169
+ params = cache_params(call)
170
+
171
+ if params is not None:
172
+ result = self.cache.get(self.chain_id, call.method, params)
173
+ if result is not None:
174
+ cached_results[idx] = result
175
+ continue
176
+
177
+ miss_indexes.append(idx)
178
+ miss_calls.append(call)
179
+
180
+ ctx.state["rpc_cache_cached_results"] = cached_results
181
+ ctx.state["rpc_cache_miss_indexes"] = miss_indexes
182
+ ctx.state["rpc_cache_miss_calls"] = miss_calls
183
+ ctx.state["rpc_cache_original_count"] = len(calls)
184
+
185
+ return miss_calls
186
+
187
+ def after_request(self, ctx, calls, results):
188
+ """Store fresh results and merge them with cached hits."""
189
+ cached_results = ctx.state.get("rpc_cache_cached_results", {})
190
+ miss_indexes = ctx.state.get("rpc_cache_miss_indexes", [])
191
+ miss_calls = ctx.state.get("rpc_cache_miss_calls", [])
192
+ original_count = ctx.state.get("rpc_cache_original_count", len(results))
193
+
194
+ merged = [None] * original_count
195
+
196
+ for idx, result in cached_results.items():
197
+ merged[idx] = result
198
+
199
+ for idx, call, result in zip(miss_indexes, miss_calls, results, strict=True):
200
+ params = cache_params(call, result)
201
+ if params is not None:
202
+ self.cache.set(self.chain_id, call.method, params, result)
203
+ merged[idx] = result
204
+
205
+ return merged
fw3_objects/chain.py ADDED
@@ -0,0 +1,317 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ from collections.abc import Iterator
6
+ from contextlib import AbstractContextManager
7
+ from typing import Any
8
+
9
+ from fw3 import Web3
10
+ from fw3.deferred import deferred_response
11
+ from fw3.validation import block_ref, hash32
12
+
13
+ from .cache.rpc import RpcCacheMiddleware
14
+ from .errors import ChainMismatch
15
+
16
+
17
+ class _DefaultChainContext(AbstractContextManager["Chain"]):
18
+ """Context manager for temporarily setting the thread-local default chain."""
19
+
20
+ def __init__(self, chain: "Chain", *, strict: bool = False) -> None:
21
+ self._chain = chain
22
+ self._strict = strict
23
+ self._previous: tuple[Chain | None, bool] = (None, False)
24
+
25
+ def __enter__(self) -> "Chain":
26
+ self._previous = Chain._get_default_chain()
27
+ previous_chain, previous_strict = self._previous
28
+
29
+ if previous_strict:
30
+ raise ChainMismatch(
31
+ previous_chain, self._chain, "cannot nest default Chain contexts inside strict mode"
32
+ )
33
+
34
+ if self._strict and previous_chain is not None and previous_chain is not self._chain:
35
+ raise ChainMismatch(
36
+ previous_chain, self._chain, "default Chain context manager in strict mode"
37
+ )
38
+
39
+ Chain._set_default_chain(self._chain, self._strict)
40
+ return self._chain
41
+
42
+ def __exit__(self, exc_type, exc, tb) -> None:
43
+ Chain._set_default_chain(*self._previous)
44
+ return None
45
+
46
+
47
+ class Chain:
48
+ """Canonical chain context for block, transaction, and contract access."""
49
+
50
+ _instances: dict[int, "Chain"] = {}
51
+ _instances_lock = threading.RLock()
52
+
53
+ _thread_local = threading.local()
54
+
55
+ def __new__(cls, chain_id: int) -> "Chain":
56
+ """Return the canonical instance for a chain ID.
57
+
58
+ Args:
59
+ chain_id: Chain ID to instantiate.
60
+
61
+ Returns:
62
+ Canonical Chain instance for the given chain ID.
63
+ """
64
+ chain_id = int(chain_id)
65
+
66
+ with cls._instances_lock:
67
+ if chain_id not in cls._instances:
68
+ cls._instances[chain_id] = super().__new__(cls)
69
+
70
+ return cls._instances[chain_id]
71
+
72
+ def __init__(self, chain_id: int) -> None:
73
+ """Initialize chain state.
74
+
75
+ Web3 configuration is stored immediately, but the Web3 client itself is created
76
+ lazily on first access.
77
+
78
+ Args:
79
+ chain_id: Chain ID to initialize.
80
+ """
81
+ if getattr(self, "_initialized", False):
82
+ return
83
+
84
+ self._initialized = True
85
+ self._chain_id = int(chain_id)
86
+ self._w3_params: dict[str, Any] = {}
87
+ self._w3: Web3 | None = None
88
+ self.__transaction_monitor = None
89
+
90
+ def __repr__(self) -> str:
91
+ """Return a developer-friendly representation."""
92
+ return f"Chain({self.id})"
93
+
94
+ def __int__(self) -> int:
95
+ """Return the numeric chain ID."""
96
+ return self.id
97
+
98
+ def __len__(self) -> int:
99
+ """Return the number of addressable block indices.
100
+
101
+ Returns:
102
+ Deferred value resolving to latest block height plus one.
103
+ """
104
+ height = self.height()
105
+ return deferred_response(None, ref_func=lambda h: h.set_value(height + 1))
106
+
107
+ def __getitem__(self, block_number: int):
108
+ """Return a block by number.
109
+
110
+ Negative indices are resolved relative to the latest block.
111
+
112
+ Args:
113
+ block_number: Absolute or negative-relative block number.
114
+
115
+ Returns:
116
+ Requested block object.
117
+
118
+ Raises:
119
+ TypeError: If ``block_number`` is not an integer.
120
+ IndexError: If a negative index resolves before genesis.
121
+
122
+ Notes:
123
+ Negative indices (``-2`` and below) require an additional RPC call to
124
+ resolve the latest block height. As a result, accessing the returned
125
+ value will perform I/O on first use.
126
+ """
127
+
128
+ if isinstance(block_number, slice):
129
+ raise TypeError("Slicing is not supported")
130
+
131
+ if not isinstance(block_number, int):
132
+ raise TypeError("block_number must be int")
133
+
134
+ if block_number > -2:
135
+ if block_number == -1:
136
+ block_number = "latest"
137
+ return self.get_block(block_number)
138
+
139
+ height = self.height()
140
+
141
+ def ref_func(handle):
142
+ blk = height + 1 + block_number
143
+ if blk < 0:
144
+ raise IndexError("block index out of range")
145
+ handle.set_value(self.get_block(blk))
146
+
147
+ return deferred_response(None, ref_func=ref_func)
148
+
149
+ @property
150
+ def id(self) -> int:
151
+ """Return the chain ID."""
152
+ return self._chain_id
153
+
154
+ @property
155
+ def w3(self) -> Web3:
156
+ """Return the configured Web3 instance for this chain.
157
+
158
+ The instance is created lazily on first access.
159
+
160
+ When a default chain context is active in strict mode, access is only
161
+ permitted on that chain. Attempting to access ``w3`` on any other chain
162
+ will raise.
163
+
164
+ Raises:
165
+ ChainMismatch: If a strict default chain context is active and this chain is
166
+ not the default chain.
167
+ """
168
+ default_chain, strict = self._get_default_chain()
169
+ if strict and default_chain is not None and default_chain is not self:
170
+ raise ChainMismatch(default_chain, self, "strict default chain")
171
+ if self._w3 is None:
172
+ self._create_w3(**self._w3_params)
173
+ return self._w3
174
+
175
+ @property
176
+ def _transaction_monitor(self):
177
+ if self.__transaction_monitor is None:
178
+ from .monitor import TransactionMonitor
179
+
180
+ self.__transaction_monitor = TransactionMonitor(self)
181
+ return self.__transaction_monitor
182
+
183
+ def height(self) -> int:
184
+ """Return the latest block number."""
185
+ return self.w3.eth.block_number()
186
+
187
+ def block_gas_limit(self) -> int:
188
+ """Return the gas limit of the latest block."""
189
+ block = self.get_block("latest")
190
+
191
+ return deferred_response(None, ref_func=lambda h: h.set_value(block["gasLimit"]))
192
+
193
+ def base_fee(self) -> int:
194
+ """Return the base fee per gas of the latest block."""
195
+ fee_history = self.w3.eth.fee_history(1, "latest", [])
196
+ return deferred_response(
197
+ None, ref_func=lambda h: h.set_value(fee_history["baseFeePerGas"][0])
198
+ )
199
+
200
+ def priority_fee(self) -> int:
201
+ """Return the suggested max priority fee per gas."""
202
+ return self.w3.eth.max_priority_fee_per_gas()
203
+
204
+ def get_transaction(self, hash: str | bytes):
205
+ """Return a transaction by hash.
206
+
207
+ Args:
208
+ hash: Transaction hash as a hex string or bytes.
209
+
210
+ Returns:
211
+ Transaction object.
212
+ """
213
+ from .transaction import Transaction
214
+
215
+ return Transaction(hash, chain=self)
216
+
217
+ def get_block(self, block_identifier: int | str | bytes):
218
+ """Return a block by number, tag, or hash.
219
+
220
+ Args:
221
+ block_identifier: Block number, block tag, or block hash.
222
+
223
+ Returns:
224
+ RPC block object.
225
+ """
226
+ if isinstance(block_identifier, bytes):
227
+ return self.w3.eth.get_block_by_hash(
228
+ hash32(block_identifier, name="block", strict=True)
229
+ )
230
+
231
+ normalized = block_ref(block_identifier, strict=True)
232
+
233
+ if (
234
+ isinstance(block_identifier, str)
235
+ and normalized.startswith("0x")
236
+ and len(normalized) == 66
237
+ ):
238
+ return self.w3.eth.get_block_by_hash(normalized)
239
+
240
+ return self.w3.eth.get_block_by_number(normalized)
241
+
242
+ def new_blocks(
243
+ self,
244
+ height_buffer: int = 0,
245
+ poll_interval: float = 5.0,
246
+ ) -> Iterator:
247
+ """Yield new blocks as they become available.
248
+
249
+ Args:
250
+ height_buffer: Number of blocks behind the tip to follow.
251
+ poll_interval: Target polling interval in seconds.
252
+
253
+ Yields:
254
+ New buffered blocks in ascending height order.
255
+
256
+ Raises:
257
+ ValueError: If ``height_buffer`` is negative or ``poll_interval`` is not
258
+ positive.
259
+ """
260
+ if height_buffer < 0:
261
+ raise ValueError("height_buffer must be >= 0")
262
+
263
+ if poll_interval <= 0:
264
+ raise ValueError("poll_interval must be > 0")
265
+
266
+ last_height = max(0, self.height() - height_buffer)
267
+
268
+ while True:
269
+ started_at = time.monotonic()
270
+ current_height = max(0, self.height() - height_buffer)
271
+
272
+ while current_height > last_height:
273
+ last_height += 1
274
+ yield self.get_block(last_height)
275
+
276
+ elapsed = time.monotonic() - started_at
277
+ time.sleep(max(0, poll_interval - elapsed))
278
+
279
+ def as_default(self, *, strict: bool = False) -> AbstractContextManager["Chain"]:
280
+ """Return a context manager that sets this thread's default chain.
281
+
282
+ Args:
283
+ strict: Whether to reject access through any other chain while the context
284
+ is active.
285
+
286
+ Returns:
287
+ Context manager that restores the previous default chain on exit.
288
+ """
289
+ return _DefaultChainContext(self, strict=strict)
290
+
291
+ def _create_w3(self, **w3_params: Any) -> None:
292
+ """Create and assign a new ``Web3`` instance for this chain."""
293
+ self._w3_params = dict(w3_params)
294
+ self._w3 = Web3(chain_id=self.id, **self._w3_params)
295
+ self._w3.provider.add_middleware(RpcCacheMiddleware(self.id))
296
+
297
+ @classmethod
298
+ def _get_default_chain(cls) -> tuple["Chain | None", bool]:
299
+ return (
300
+ getattr(cls._thread_local, "default_chain", None),
301
+ getattr(cls._thread_local, "default_chain_strict", False),
302
+ )
303
+
304
+ @classmethod
305
+ def _set_default_chain(cls, chain: "Chain | None", strict: bool) -> None:
306
+ cls._thread_local.default_chain = chain
307
+ cls._thread_local.default_chain_strict = strict
308
+
309
+
310
+ def configure_chain(chain: Chain | int, **w3_params: Any) -> None:
311
+ """Configure the canonical Chain instance for a chain ID.
312
+
313
+ Args:
314
+ chain: Chain instance or chain ID to configure.
315
+ **w3_params: Keyword arguments forwarded to ``Web3``.
316
+ """
317
+ Chain(chain)._create_w3(**w3_params)