Skip to content

Commit 9ae2f6d

Browse files
authored
Add support for TON collectors (#71)
* add support for TON collectors * change to cached post * refactor for pylint
1 parent 1eacd87 commit 9ae2f6d

File tree

5 files changed

+370
-208
lines changed

5 files changed

+370
-208
lines changed

src/collectors.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,3 +548,59 @@ def client_version(self):
548548
def latency(self):
549549
"""Returns connection latency."""
550550
return self.interface.latest_query_latency
551+
552+
class TonCollector():
553+
"""A collector to fetch information about Ton endpoints."""
554+
555+
def __init__(self, url, labels, chain_id, **client_parameters):
556+
557+
self.labels = labels
558+
self.chain_id = chain_id
559+
self.interface = HttpsInterface(url.rstrip("/") + "/jsonRPC",
560+
client_parameters.get('open_timeout'),
561+
client_parameters.get('ping_timeout'))
562+
self._logger_metadata = {
563+
'component': 'TonCollector',
564+
'url': strip_url(url)
565+
}
566+
self.block_height_payload = {
567+
'jsonrpc': '2.0',
568+
'method': "getMasterchainInfo",
569+
'id': 1
570+
}
571+
self.consensus_block_height_payload = {
572+
'jsonrpc': '2.0',
573+
'method': "getConsensusBlock",
574+
'id': 1
575+
}
576+
577+
def alive(self):
578+
"""Returns true if endpoint is alive, false if not."""
579+
# Run cached query because we can also fetch block height from this
580+
# later on. This will save us an RPC call per run.
581+
return self.interface.cached_json_rpc_post(
582+
self.block_height_payload) is not None
583+
584+
def block_height(self):
585+
"""Returns latest block height."""
586+
result = self.interface.cached_json_rpc_post(self.block_height_payload)
587+
if result is None:
588+
raise ValueError("No response received from TON endpoint")
589+
block_height = result.get('last', {}).get('seqno', None)
590+
if block_height is not None:
591+
return block_height
592+
raise ValueError(f"Invalid block height result: {result}")
593+
594+
def finalized_block_height(self):
595+
"""Runs a query to return consensus block height"""
596+
result = self.interface.cached_json_rpc_post(self.consensus_block_height_payload)
597+
if result is None:
598+
raise ValueError("No response received from TON endpoint")
599+
consensus_block = result.get('consensus_block', None)
600+
if consensus_block is not None:
601+
return consensus_block
602+
raise ValueError(f"Invalid consensus block height result: {result}")
603+
604+
def latency(self):
605+
"""Returns connection latency."""
606+
return self.interface.latest_query_latency

src/configuration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def endpoints(self):
4545
def _load_configuration(self):
4646
supported_collectors = ('evm', 'evmhttp', 'cardano', 'conflux', 'solana',
4747
'bitcoin', 'doge', 'filecoin', 'starknet', 'aptos',
48-
'tron', 'xrpl')
48+
'tron', 'xrpl', 'ton')
4949

5050
configuration_schema = Schema({
5151
'blockchain':

src/registries.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ def get_collector_registry(self) -> list:
9090
collector = collectors.AptosCollector
9191
case "xrpl", "xrpl":
9292
collector = collectors.XRPLCollector
93+
case "ton", "ton":
94+
collector = collectors.TonCollector
9395
case "evmhttp", other: # pylint: disable=unused-variable
9496
collector = collectors.EvmHttpCollector
9597
case "evm", other: # pylint: disable=unused-variable

src/test_collectors_evm.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# pylint: disable=protected-access, too-many-instance-attributes, duplicate-code
2+
"""Module for testing collectors"""
3+
from unittest import TestCase, mock
4+
5+
import collectors
6+
7+
8+
class TestEvmCollector(TestCase):
9+
"""Tests the evm collector class"""
10+
11+
def setUp(self):
12+
self.url = "wss://test.com"
13+
self.labels = ["dummy", "labels"]
14+
self.chain_id = 123
15+
self.client_params = {"param1": "dummy", "param2": "data"}
16+
self.sub_payload = {
17+
"method": 'eth_subscribe',
18+
"jsonrpc": "2.0",
19+
"id": self.chain_id,
20+
"params": ["newHeads"]
21+
}
22+
with mock.patch('collectors.WebsocketInterface') as mocked_websocket:
23+
self.evm_collector = collectors.EvmCollector(
24+
self.url, self.labels, self.chain_id, **self.client_params)
25+
self.mocked_websocket = mocked_websocket
26+
27+
def test_websocket_interface_created(self):
28+
"""Tests that the evm collector calls the websocket interface with the correct args"""
29+
self.mocked_websocket.assert_called_once_with(
30+
self.url, self.sub_payload, **self.client_params)
31+
32+
def test_interface_attribute_exists(self):
33+
"""Tests that the interface attribute exists.
34+
May be used by external calls to access objects such as the interface cache"""
35+
self.assertTrue(hasattr(self.evm_collector, 'interface'))
36+
37+
def test_websocket_attr_daemon_is_bool(self):
38+
"""Tests that the daemon attribute is of type bool"""
39+
self.assertEqual(bool, type(self.mocked_websocket.return_value.daemon))
40+
41+
def test_websocket_daemon_true(self):
42+
"""Tests that the websocket object has daemon set to true"""
43+
self.assertTrue(self.mocked_websocket.return_value.daemon)
44+
45+
def test_websocket_start_called(self):
46+
"""Tests that the websocket object start function is called"""
47+
self.mocked_websocket.return_value.start.assert_called_once_with()
48+
49+
def test_alive_is_true(self):
50+
"""Tests the alive function returns true when websocket.healthy is true"""
51+
self.mocked_websocket.return_value.healthy = True
52+
self.assertTrue(self.evm_collector.alive())
53+
54+
def test_alive_is_false(self):
55+
"""Tests the alive function returns false when websocket.healthy is false"""
56+
self.mocked_websocket.return_value.healthy = False
57+
self.assertFalse(self.evm_collector.alive())
58+
59+
def test_block_height(self):
60+
"""Tests the block_height function uses the correct call and args to get block height"""
61+
self.evm_collector.block_height()
62+
self.mocked_websocket.return_value.get_message_property_to_hex.assert_called_once_with(
63+
'number')
64+
65+
def test_finalized_block_height(self):
66+
"""Tests that finalized_block_height uses correct call and args to get finalized block"""
67+
# Mock with hex string, not integer
68+
mock_block_response = {"number": "0x1a2b3c"}
69+
self.mocked_websocket.return_value.query.return_value = mock_block_response
70+
71+
payload = {
72+
"jsonrpc": "2.0",
73+
"method": "eth_getBlockByNumber",
74+
"params": ["finalized", False],
75+
"id": self.chain_id
76+
}
77+
self.evm_collector.finalized_block_height()
78+
self.mocked_websocket.return_value.query.assert_called_once_with(payload)
79+
80+
def test_finalized_block_height_return_none_when_query_none(self):
81+
"""Tests that finalized_block_height returns None if the query returns None"""
82+
self.mocked_websocket.return_value.query.return_value = None
83+
result = self.evm_collector.finalized_block_height()
84+
self.assertEqual(None, result)
85+
86+
def test_finalized_block_height_return_none_when_no_number_field(self):
87+
"""Tests that finalized_block_height returns None if the response has no 'number' field"""
88+
self.mocked_websocket.return_value.query.return_value = {"hash": "0x123"}
89+
result = self.evm_collector.finalized_block_height()
90+
self.assertEqual(None, result)
91+
92+
def test_finalized_block_height_return(self):
93+
"""Tests that finalized_block_height converts hex block number to integer correctly"""
94+
mock_block_response = {
95+
"number": "0x1a2b3c", # Hex string as your code expects
96+
"hash": "0x456def"
97+
}
98+
self.mocked_websocket.return_value.query.return_value = mock_block_response
99+
result = self.evm_collector.finalized_block_height()
100+
# 0x1a2b3c = 1715004 in decimal
101+
self.assertEqual(1715004, result)
102+
103+
def test_client_version(self):
104+
"""Tests the client_version function uses the correct call and args to get client version"""
105+
payload = {
106+
"jsonrpc": "2.0",
107+
"method": "web3_clientVersion",
108+
"params": [],
109+
"id": self.chain_id
110+
}
111+
self.evm_collector.client_version()
112+
self.mocked_websocket.return_value.cached_query.assert_called_once_with(
113+
payload)
114+
115+
def test_client_version_return_none(self):
116+
"""Tests that the client_version returns None if the query returns no version"""
117+
self.mocked_websocket.return_value.cached_query.return_value = None
118+
result = self.evm_collector.client_version()
119+
self.assertEqual(None, result)
120+
121+
def test_client_version_return(self):
122+
"""Tests that the client_version is returned in the correct format"""
123+
self.mocked_websocket.return_value.cached_query.return_value = "test/v1.23"
124+
result = self.evm_collector.client_version()
125+
self.assertEqual({"client_version": "test/v1.23"}, result)
126+
127+
def test_latency(self):
128+
"""Tests that the latency is obtained from the interface based on subscription ping"""
129+
self.mocked_websocket.return_value.subscription_ping_latency = 0.123
130+
self.assertEqual(0.123, self.evm_collector.latency())
131+
132+
class TestEvmHttpCollector(TestCase):
133+
"""Tests the EvmHttp collector class"""
134+
135+
def setUp(self):
136+
self.url = "https://test.com"
137+
self.labels = ["dummy", "labels"]
138+
self.chain_id = 123
139+
self.open_timeout = 8
140+
self.ping_timeout = 9
141+
self.client_params = {
142+
"open_timeout": self.open_timeout, "ping_timeout": self.ping_timeout}
143+
with mock.patch('collectors.HttpsInterface') as mocked_connection:
144+
self.evmhttp_collector = collectors.EvmHttpCollector(
145+
self.url, self.labels, self.chain_id, **self.client_params)
146+
self.mocked_connection = mocked_connection
147+
148+
def test_logger_metadata(self):
149+
"""Validate logger metadata. Makes sure url is stripped by helpers.strip_url function."""
150+
expected_metadata = {
151+
'component': 'EvmHttpCollector', 'url': 'test.com'}
152+
self.assertEqual(expected_metadata,
153+
self.evmhttp_collector._logger_metadata)
154+
155+
def test_https_interface_created(self):
156+
"""Tests that the EvmHttp collector calls the https interface with the correct args"""
157+
self.mocked_connection.assert_called_once_with(
158+
self.url, self.open_timeout, self.ping_timeout)
159+
160+
def test_interface_attribute_exists(self):
161+
"""Tests that the interface attribute exists."""
162+
self.assertTrue(hasattr(self.evmhttp_collector, 'interface'))
163+
164+
def test_alive_call(self):
165+
"""Tests the alive function uses the correct call"""
166+
self.evmhttp_collector.alive()
167+
self.mocked_connection.return_value.cached_json_rpc_post.assert_called_once_with(
168+
self.evmhttp_collector.client_version_payload)
169+
170+
def test_alive_false(self):
171+
"""Tests the alive function returns false when post returns None"""
172+
self.mocked_connection.return_value.cached_json_rpc_post.return_value = None
173+
result = self.evmhttp_collector.alive()
174+
self.assertFalse(result)
175+
176+
def test_block_height(self):
177+
"""Tests the block_height function uses the correct call to get block height"""
178+
self.mocked_connection.return_value.cached_json_rpc_post.return_value = "0x1a2b3c"
179+
result = self.evmhttp_collector.block_height()
180+
self.mocked_connection.return_value.cached_json_rpc_post.assert_called_once_with(
181+
self.evmhttp_collector.block_height_payload)
182+
self.assertEqual(result, 1715004)
183+
184+
def test_block_height_raises_value_error(self):
185+
"""Tests that the block height raises ValueError if result is invalid"""
186+
self.mocked_connection.return_value.cached_json_rpc_post.return_value = "invalid"
187+
with self.assertRaises(ValueError):
188+
self.evmhttp_collector.block_height()
189+
190+
def test_client_version(self):
191+
"""Tests the client_version function uses the correct call and args to get client version"""
192+
payload = {
193+
"jsonrpc": "2.0",
194+
"method": "web3_clientVersion",
195+
"id": 1
196+
}
197+
self.evmhttp_collector.client_version()
198+
self.mocked_connection.return_value.cached_json_rpc_post.assert_called_once_with(
199+
payload)
200+
201+
def test_client_version_returns_none(self):
202+
"""Tests that the client_version returns None if cached_json_rpc_post returns None"""
203+
self.mocked_connection.return_value.cached_json_rpc_post.return_value = None
204+
result = self.evmhttp_collector.client_version()
205+
self.assertIsNone(result)
206+
207+
def test_latency(self):
208+
"""Tests that the latency is obtained from the interface based on latest_query_latency"""
209+
self.mocked_connection.return_value.latest_query_latency = 0.123
210+
self.assertEqual(0.123, self.evmhttp_collector.latency())

0 commit comments

Comments
 (0)