├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── preview_1.jpeg ├── preview_2.jpeg ├── requirements.txt └── src ├── api └── everspace_center_api.py ├── config.py └── main.py /.env.example: -------------------------------------------------------------------------------- 1 | EVERSPACE_CENTER_API_KEY="API_KEY" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # macOS 10 | .DS_Store 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 dfmode 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blockchain Network Visualizer 2 | 3 | ``` 4 | pip install -r requirements.txt 5 | python3 src/main.py 6 | ``` 7 | 8 | ### Pull requests are welcome 9 | 10 | # Preview 11 | ![Preview 2](preview_2.jpeg) 12 | ![Preview 1](preview_1.jpeg) 13 | 14 | -------------------------------------------------------------------------------- /preview_1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qpwedev/blockchain-network-visualizer/3d7935141cfd4f338887f86fdabde1639fbebf80/preview_1.jpeg -------------------------------------------------------------------------------- /preview_2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qpwedev/blockchain-network-visualizer/3d7935141cfd4f338887f86fdabde1639fbebf80/preview_2.jpeg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiogram==2.25.1 2 | aiohttp==3.8.4 3 | aiosignal==1.3.1 4 | anyio==3.6.2 5 | httpcore==0.17.0 6 | httpx==0.24.0 7 | pyvis==0.3.1 8 | requests==2.28.2 9 | tqdm==4.65.0 10 | -------------------------------------------------------------------------------- /src/api/everspace_center_api.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import math 3 | from typing import List 4 | import httpx 5 | from config import ApiConfig 6 | 7 | 8 | class EverspaceCenterApi: 9 | """ 10 | A class to interact with the Everspace Center API. 11 | https://everspace.center/toncoin 12 | """ 13 | 14 | def __init__(self, config: ApiConfig): 15 | self.config = config 16 | 17 | async def get_transactions(self, address: str, limit: int) -> List[dict]: 18 | """ 19 | Get transactions for a given address asynchronously. 20 | :param address: The address for which to fetch the transactions. 21 | :param limit: The maximum number of transactions to fetch. 22 | :return: A list of transactions if successful, an empty list otherwise. 23 | """ 24 | 25 | headers = { 26 | 'X-API-KEY': self.config.api_key, 27 | } 28 | 29 | max_limit = 50 30 | num_requests = math.ceil(limit / max_limit) 31 | 32 | all_transactions = [] 33 | 34 | for _ in range(num_requests): 35 | params = { 36 | 'address': address, 37 | 'limit': max_limit, 38 | } 39 | 40 | if all_transactions: 41 | params['lt'] = all_transactions[-1]['lt'] 42 | 43 | for _ in range(self.config.retries): 44 | try: 45 | async with httpx.AsyncClient() as client: 46 | response = await client.get( 47 | 'https://everspace.center/toncoin/getTransactions', 48 | params=params, headers=headers, timeout=self.config.timeout 49 | ) 50 | response_json = response.json() 51 | 52 | if response.status_code == 200: 53 | # Extract the "result" field containing the transactions 54 | all_transactions.extend(response_json) 55 | 56 | if len(response_json) < max_limit: 57 | break 58 | 59 | else: 60 | await asyncio.sleep(self.config.delay) 61 | except httpx.HTTPError: 62 | await asyncio.sleep(self.config.delay) 63 | 64 | if len(all_transactions) > limit: 65 | # Return only the requested number of transactions 66 | return all_transactions[:limit] 67 | else: 68 | return all_transactions 69 | 70 | async def get_balance(self, address: str) -> float: 71 | """ 72 | Get the balance for a given address asynchronously. 73 | :param address: The address for which to fetch the balance. 74 | :return: The balance if successful, 0 otherwise. 75 | """ 76 | headers = { 77 | 'X-API-KEY': self.config.api_key, 78 | } 79 | params = { 80 | 'address': address, 81 | } 82 | 83 | for _ in range(self.config.retries): 84 | try: 85 | async with httpx.AsyncClient() as client: 86 | response = await client.get( 87 | 'https://everspace.center/toncoin/getBalance', 88 | params=params, headers=headers, timeout=self.config.timeout 89 | ) 90 | response_json = response.json() 91 | if response.status_code == 200: 92 | balance = int(response_json['balance']) 93 | return balance / 1e9 94 | 95 | await asyncio.sleep(self.config.delay) 96 | except httpx.HTTPError: 97 | await asyncio.sleep(self.config.delay) 98 | 99 | return 0 100 | -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | DEPTH = 2 4 | TX_LIMIT = 500 5 | 6 | 7 | class ApiConfig: 8 | """ 9 | Configuration for the API. 10 | """ 11 | 12 | def __init__(self, api_key: str, timeout: int, retries: int, delay: int): 13 | self.api_key = api_key 14 | self.timeout = timeout 15 | self.retries = retries 16 | self.delay = delay 17 | 18 | 19 | EVERSPACE_CENTER_CONFIG = ApiConfig( 20 | api_key="b17a652df5d642a6aa6e9dae4601685a", 21 | timeout=10, 22 | retries=3, 23 | delay=1, 24 | ) 25 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import json 2 | import asyncio 3 | import os 4 | from tqdm import tqdm 5 | from typing import List, Dict, Tuple 6 | from pyvis.network import Network 7 | from config import EVERSPACE_CENTER_CONFIG, TX_LIMIT, DEPTH 8 | from api.everspace_center_api import EverspaceCenterApi 9 | import logging 10 | 11 | SUSPICIOUS_ADDRESSES = [] 12 | 13 | 14 | class Transaction: 15 | def __init__(self, id: str, address_from: str, address_to: str, value: int, type: str): 16 | self.id = id 17 | self.address_from = address_from 18 | self.address_to = address_to 19 | self.value = value / 1e9 20 | self.type = type 21 | 22 | 23 | def parse_raw_tx(tx): 24 | if abs(int(tx['total_fees'])) == abs(int(tx['balance_delta'])): 25 | return 26 | 27 | tx_raw = { 28 | 'id': tx['id'], 29 | 'address_from': tx['in_message']['src'] if tx['in_message']['src'] else tx['in_message']['dst'], 30 | 'address_to': tx['in_message']['dst'], 31 | 'value': int(tx['out_messages'][0]['value']) if tx['out_messages'] and 'value' in tx['out_messages'][0] 32 | else int(tx['in_message']['value']), 33 | 'type': 'out' if tx['out_messages'] else 'in', 34 | } 35 | 36 | tx = Transaction( 37 | id=tx_raw['id'], 38 | address_from=tx_raw['address_from'], 39 | address_to=tx_raw['address_to'], 40 | value=tx_raw['value'], 41 | type=tx_raw['type'], 42 | ) 43 | 44 | if tx.address_from == tx.address_to: 45 | return 46 | 47 | return tx 48 | 49 | 50 | def process_raw_txs(transactions: List[Dict]) -> Tuple[Dict, Dict]: 51 | txs = [ 52 | parse_raw_tx(tx) 53 | for tx in transactions 54 | if parse_raw_tx(tx) 55 | ] 56 | 57 | return txs 58 | 59 | 60 | def configure_network(): 61 | net = Network(height="750px", width="100%", bgcolor="#222222", 62 | font_color="white", directed=True) 63 | 64 | options = { 65 | 'physics': { 66 | 'solver': 'forceAtlas2Based', 67 | 'forceAtlas2Based': { 68 | 'gravitationalConstant': -100, 69 | 'centralGravity': 0.01, 70 | 'springLength': 200, 71 | 'springConstant': 0.05, 72 | 'damping': 0.4, 73 | 'avoidOverlap': 1 74 | }, 75 | 'minVelocity': 0.75, 76 | 'timestep': 0.5 77 | }, 78 | 'nodes': { 79 | 'shape': 'dot', 80 | 'scaling': { 81 | 'min': 10, 82 | 'max': 30 83 | } 84 | } 85 | } 86 | 87 | options_json = json.dumps(options) 88 | 89 | net.set_options(options_json) 90 | 91 | net.show('network.html') 92 | 93 | return net 94 | 95 | 96 | async def fetch_balance(address, everspaceCenterApi): 97 | return address, await everspaceCenterApi.get_balance(address) 98 | 99 | 100 | async def create_graph(txs: Dict, addresses: List[str], everspaceCenterApi: EverspaceCenterApi, initial_address: str) -> Network: 101 | net = configure_network() 102 | 103 | # Parallelize balance fetching 104 | tasks = [asyncio.create_task(fetch_balance( 105 | address, everspaceCenterApi)) for address in addresses] 106 | results = await asyncio.gather(*tasks) 107 | 108 | for address, balance in tqdm(results): 109 | label = f'({balance:.2f}) ' + address[:3] + '...' + address[-3:] 110 | 111 | color = '#0088CC' 112 | 113 | if initial_address == address: 114 | color = '#FF0000' 115 | 116 | if address in SUSPICIOUS_ADDRESSES: 117 | color = '#FF00FF' 118 | 119 | net.add_node(address, label=label, title=address, 120 | value=balance, color=color) 121 | 122 | for i, (_, tx) in enumerate(tqdm(txs.items(), desc="Adding edges", unit="transaction")): 123 | try: 124 | net.add_edge( 125 | tx.address_from, 126 | tx.address_to, 127 | value=tx.value, 128 | color='green', 129 | title=tx.value 130 | ) 131 | except AssertionError as e: 132 | logging.warning(f'Edge {i + 1}/{len(txs)} not added: {e}') 133 | 134 | return net 135 | 136 | 137 | async def process_address(address, everspace_center_api: EverspaceCenterApi, tx_limit, new_addresses, full_transactions_data, processed_addresses): 138 | if address not in processed_addresses: 139 | raw_transactions = await everspace_center_api.get_transactions( 140 | address, 141 | tx_limit 142 | ) 143 | 144 | if len(raw_transactions) < 5: 145 | SUSPICIOUS_ADDRESSES.append(address) 146 | 147 | txs = process_raw_txs(raw_transactions) 148 | 149 | for tx in txs: 150 | new_addresses.add(tx.address_from) 151 | new_addresses.add(tx.address_to) 152 | if tx.id not in full_transactions_data: 153 | full_transactions_data[tx.id] = tx 154 | 155 | processed_addresses.append(address) 156 | 157 | 158 | async def main(): 159 | logging.basicConfig(level=logging.INFO) 160 | everspace_center_api = EverspaceCenterApi(EVERSPACE_CENTER_CONFIG) 161 | 162 | initial_address = '0:62a4c922bd869538396cb8994a67b0cfddf75b17860739fe88043349f1bc7c9c' 163 | addresses = [initial_address] 164 | processed_addresses = [] 165 | full_transactions_data = {} 166 | 167 | tx_limit = TX_LIMIT 168 | 169 | for i in range(DEPTH): 170 | logging.info('Layer: ' + str(i + 1)) 171 | new_addresses = set() 172 | 173 | tasks = [] 174 | for address in tqdm(addresses): 175 | if i >= 1: 176 | tx_limit = 50 177 | task = asyncio.create_task(process_address( 178 | address, everspace_center_api, tx_limit, new_addresses, full_transactions_data, processed_addresses)) 179 | tasks.append(task) 180 | 181 | await asyncio.gather(*tasks) 182 | 183 | addresses = set(new_addresses) 184 | logging.info('Visualizing..') 185 | net = await create_graph(full_transactions_data, addresses, everspace_center_api, initial_address) 186 | 187 | if not os.path.exists('./html'): 188 | os.makedirs('./html') 189 | 190 | directory = './html/' 191 | file_name = 'index.html' 192 | 193 | net.show(directory + file_name) 194 | logging.info('Ready.') 195 | 196 | asyncio.run(main()) 197 | --------------------------------------------------------------------------------