Skip to content

Commit faf4e55

Browse files
echonet: add get_block_number_by_timestamp func (#10265)
1 parent d9ffd19 commit faf4e55

File tree

2 files changed

+97
-6
lines changed

2 files changed

+97
-6
lines changed

echonet/l1_client.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from dataclasses import dataclass
2+
from datetime import datetime, timezone
23
from typing import List, Optional
34

45
import logging
@@ -9,6 +10,15 @@
910

1011
class L1Client:
1112
L1_MAINNET_URL = "https://eth-mainnet.g.alchemy.com/v2/{api_key}"
13+
DATA_BLOCKS_BY_TIMESTAMP_URL_FMT = (
14+
"https://api.g.alchemy.com/data/v1/{api_key}/utility/blocks/by-timestamp"
15+
)
16+
# Taken from apollo_l1_provider/src/lib.rs
17+
LOG_MESSAGE_TO_L2_EVENT_SIGNATURE = (
18+
"0xdb80dd488acf86d17c747445b0eabb5d57c541d3bd7b6b87af987858e5066b2b"
19+
)
20+
# Taken from ethereum_base_layer_contracts.rs
21+
STARKNET_L1_CONTRACT_ADDRESS = "0xc662c410C0ECf747543f5bA90660f6ABeBD9C8c4"
1222
RETRIES_COUNT = 2
1323

1424
@dataclass(frozen=True)
@@ -46,11 +56,8 @@ def get_logs(from_block: int, to_block: int, api_key: str) -> List["L1Client.Log
4656
{
4757
"fromBlock": hex(from_block),
4858
"toBlock": hex(to_block),
49-
"address": "0xc662c410C0ECf747543f5bA90660f6ABeBD9C8c4", # Starknet L1 contract
50-
"topics": [
51-
"0xdb80dd488acf86d17c747445b0eabb5d57c541d3bd7b6b87af987858e5066b2b"
52-
# LogMessageToL2 event signature
53-
],
59+
"address": L1Client.STARKNET_L1_CONTRACT_ADDRESS,
60+
"topics": [L1Client.LOG_MESSAGE_TO_L2_EVENT_SIGNATURE],
5461
}
5562
],
5663
"id": 1,
@@ -74,7 +81,7 @@ def get_logs(from_block: int, to_block: int, api_key: str) -> List["L1Client.Log
7481
)
7582
else:
7683
logger.error(
77-
f"get_logs failed after {L1Client.RETRIES_COUNT} attempts",
84+
f"get_logs failed after {L1Client.RETRIES_COUNT} attempts, returning []",
7885
extra={"url": rpc_url, "from_block": from_block, "to_block": to_block},
7986
)
8087
return []
@@ -143,3 +150,51 @@ def get_timestamp_of_block(block_number: int, api_key: str) -> Optional[int]:
143150

144151
# Timestamp is hex string, convert to int.
145152
return int(block["timestamp"], 16)
153+
154+
@staticmethod
155+
def get_block_number_by_timestamp(timestamp: int, api_key: str) -> Optional[int]:
156+
"""
157+
Get the block number at/after a given timestamp using blocks-by-timestamp API.
158+
Tries up to RETRIES_COUNT times. On failure, logs an error and returns None.
159+
"""
160+
rpc_url = L1Client.DATA_BLOCKS_BY_TIMESTAMP_URL_FMT.format(api_key=api_key)
161+
162+
timestamp_iso = (
163+
datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat().replace("+00:00", "Z")
164+
)
165+
166+
params = {
167+
"networks": "eth-mainnet",
168+
"timestamp": timestamp_iso,
169+
"direction": "AFTER",
170+
}
171+
172+
for attempt in range(L1Client.RETRIES_COUNT):
173+
try:
174+
response = requests.get(rpc_url, params=params, timeout=10)
175+
response.raise_for_status()
176+
data = response.json()
177+
logger.debug(
178+
f"get_block_number_by_timestamp succeeded on attempt {attempt + 1}",
179+
extra={"url": rpc_url, "timestamp": timestamp},
180+
)
181+
break # success -> exit loop
182+
except (requests.RequestException, ValueError) as exc:
183+
logger.debug(
184+
f"get_block_number_by_timestamp attempt {attempt + 1}/{L1Client.RETRIES_COUNT} failed",
185+
extra={"url": rpc_url, "timestamp": timestamp},
186+
exc_info=True,
187+
)
188+
else:
189+
logger.error(
190+
f"get_block_number_by_timestamp failed after {L1Client.RETRIES_COUNT} attempts, returning None",
191+
extra={"url": rpc_url, "timestamp": timestamp},
192+
)
193+
return None
194+
195+
items = data.get("data", [])
196+
if not items:
197+
return None
198+
199+
block = items[0].get("block", {})
200+
return block.get("number")

echonet/tests/test_l1_client.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,42 @@ def test_get_timestamp_of_block_returns_none_when_rpc_result_is_empty(self, mock
204204
self.assertEqual(mock_post.call_count, 1)
205205
self.assertIsNone(result)
206206

207+
@patch("l1_client.requests.get")
208+
def test_get_block_number_by_timestamp_retries_after_failure_and_succeeds(self, mock_get):
209+
request_exception = requests.RequestException("some error")
210+
211+
successful_response = Mock()
212+
successful_response.raise_for_status.return_value = None
213+
successful_response.json.return_value = {
214+
"data": [{"network": "string", "block": {"number": 123456, "timestamp": "string"}}]
215+
}
216+
217+
mock_get.side_effect = [request_exception, successful_response]
218+
219+
result = L1Client.get_block_number_by_timestamp(
220+
timestamp=1_600_000_000,
221+
api_key="api_key",
222+
)
223+
224+
self.assertEqual(mock_get.call_count, 2)
225+
self.assertEqual(result, 123456)
226+
227+
@patch("l1_client.requests.get")
228+
def test_get_block_number_by_timestamp_returns_none_when_rpc_result_is_empty(self, mock_get):
229+
response_ok = Mock()
230+
response_ok.raise_for_status.return_value = None
231+
response_ok.json.return_value = {"data": []}
232+
233+
mock_get.return_value = response_ok
234+
235+
result = L1Client.get_block_number_by_timestamp(
236+
timestamp=1_600_000_000,
237+
api_key="api_key",
238+
)
239+
240+
self.assertEqual(mock_get.call_count, 1)
241+
self.assertIsNone(result)
242+
207243

208244
if __name__ == "__main__":
209245
unittest.main()

0 commit comments

Comments
 (0)