commit 123e0599db8a630a58b70e4fed88e17f5090b4e4 Author: Uncle Stretch Date: Mon Apr 6 17:48:36 2026 +0300 initial bot notification with <3 Signed-off-by: Uncle Stretch diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..bcb77ef --- /dev/null +++ b/.env.template @@ -0,0 +1,3 @@ +TELEGRAM_TOKEN= +NOTIFY_CHAT_ID= +THREAD_ID= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bba94e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +.env + +*.py[cod] +*$py.class +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc15fdb --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Ghost Notifier Bot + +A Telegram bot that monitors onchain events on +[ghostDAO](https://app.dao.ghostchain.io/) and sends real-time +notifications to a Telegram group using `aiogram`. + +## Features + +- Listens to specified onchain events (e.g., swaps, bond purchases, proposals creation) +- Sends instant notifications to a configured Telegram group +- Built with `aiogram` for async Telegram bot functionality +- Usage of public RPC endpoints from [chainlist](https://chainlist.org/) + +## Networks + +Each network has unique name, explorer link and event details to +listen on: + +- Event name that will be used for messages only +- Address is address of smart contract to listen on +- Event topic is `keccak256` hash of the event aka `topic[0]` + +For more info go to `networks.json`. + +## Messages + +All possible messages mapped to the event name. It is using predefined +structure of message: + +- Header is bold text on top of the message +- Body is main text of the message +- Link is text of the link (in out case is the link to transaction hash) +- Footer is text in the end of the message +- Button text is text of the inline button +- Button URL is the template link for the inline button + +## RPCs + +Becase we are aiming to use public RPC endpoints we need to have list +of enpoints in `rpcs/NETWORK_NAME.txt` where network name is described +inside `networks.json`. \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..dd26aa8 --- /dev/null +++ b/main.py @@ -0,0 +1,201 @@ +import os +import json +import asyncio +import logging + +from dotenv import load_dotenv +from web3 import Web3 +from aiogram import types, Bot +from aiogram.utils.keyboard import InlineKeyboardBuilder + +load_dotenv() +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("GhostNotifierBot") + +SLEEP_TIME=10 # seconds +CONNECTION_TIMEOUT=10 # seconds +MAX_BLOCK_DELAY=5 # blocks + +class RPCManager: + def __init__(self, network_name): + self.network_name = network_name + self.current_index = 0 + self.rpcs = [] + + async def start(self): + asyncio.create_task(self._auto_update_loop()) + + def next_rpc(self): + if len(self.rpcs) > 0: + self.current_index = (self.current_index + 1) % len(self.rpcs) + + def get_current_rpc(self): + if len(self.rpcs) == 0: + return "" + return self.rpcs[self.current_index] + + def update_rpc_from_file(self): + path = os.path.join("rpcs", f"{self.network_name.lower()}.txt") + if os.path.exists(path): + with open(path, "r") as f: + rpcs = [line.strip() for line in f if line.strip()] + self.rpcs = rpcs + self.current_index %= len(self.rpcs) + logger.info(f"[{self.network_name}] list of RPC updated.") + else: + logger.warning(f"[{self.network_name}] file with RPCs not found at {path}.") + + async def _auto_update_loop(self): + while True: + self.update_rpc_from_file() + await asyncio.sleep(1800) # 30 mins + +class EventBot: + def __init__(self, token, chat_id, thread_id, messages): + self.bot = Bot(token) + self.chat_id = chat_id + self.thread_id = thread_id + self.messages = messages + + def prepare_message(self, network_name, explorer_tx_link, event_name, log): + msgs = self.messages.get(event_name) + if msgs is None: + return f"{event_name} has no explanation text yet. =(" + + tx_link = f"{explorer_tx_link}{log['transactionHash'].to_0x_hex()}" + message = f"*{msgs['header']} #{network_name}!*\n\n" + message += f"{msgs['body']}\n[{msgs['link']}]({tx_link})\n\n" + message += f"_{msgs['footer']}_" + return message + + def prepare_button(self, network_name, event_name, ftso_address, log): + msgs = self.messages.get(event_name) + if msgs is None: + return None + + button_url = msgs["button_url"] + button_url = button_url.replace("/#/", f"/#/{network_name}/") + button = InlineKeyboardBuilder() + + if event_name == "Swap": + button_url += ftso_address + elif event_name == "Bond" or event_name == "MarketCreated": + bond_id = int(log["topics"][1].hex(), 16) + button_url += str(bond_id) + elif event_name == "ProposalCreated": + proposal_id = int(log["topics"][1].hex(), 16) + button_url += str(proposal_id) + + button.add(types.InlineKeyboardButton( + text=msgs["button_text"], + url=button_url + )) + + return button.as_markup() + + async def monitor_network(self, network_name, network_data): + rpc_manager = RPCManager(network_name) + await rpc_manager.start() + last_block = None + + while True: + checked_index = 0 + current_rpc = rpc_manager.get_current_rpc() + + try: + w3 = Web3(Web3.HTTPProvider(current_rpc, request_kwargs={'timeout': CONNECTION_TIMEOUT})) + if not w3.is_connected(): + raise Exception(f"[{network_name}] Could not connect to RPC: {current_rpc}.") + + logger.info(f"[{network_name}] Connecting to {current_rpc}.") + if last_block is None: + last_block = w3.eth.block_number + logger.info(f"[{network_name}] Starting from {last_block} based on {current_rpc}.") + + while True: + latest_block = w3.eth.block_number + + if last_block >= latest_block: + await asyncio.sleep(SLEEP_TIME) + continue + + current_block = min(last_block + MAX_BLOCK_DELAY, latest_block) + logger.info(f"[{network_name}] Looking for range from {last_block + 1} to {current_block}") + + for index, event in enumerate(network_data["events"]): + if checked_index > index: + continue + + event_name = event["name"] + logs = w3.eth.get_logs({ + "fromBlock": last_block + 1, + "toBlock": current_block, + "address": Web3.to_checksum_address(event["address"]), + "topics": [event["topic"]] + }) + checked_index = index + 1 + + logger.info(f"""[{network_name}] Found {len(logs)} {event_name} events from {current_rpc}.""") + await asyncio.sleep(2) + + for log in logs: + message = self.prepare_message( + network_name, + network_data["explorer_tx_link"], + event_name, + log + ) + button = self.prepare_button( + network_name, + event_name, + network_data["ftso"], + log + ) + await self.send_alert(message, button) + + checked_index = 0 + last_block = current_block + await asyncio.sleep(SLEEP_TIME) + + except Exception as e: + logger.warning(f"[{network_name}] Connection failed {current_rpc}: {e}.") + await asyncio.sleep(2) + + finally: + rpc_manager.next_rpc() + + async def send_alert(self, message, button_reply_markup): + try: + await self.bot.send_message( + chat_id=self.chat_id, + message_thread_id=self.thread_id, + text=message, + parse_mode="Markdown", + reply_markup=button_reply_markup + ) + await asyncio.sleep(0.5) + except Exception as e: + logger.error(f"[{network}] TG error during sending: {e}.") + + async def start(self, networks): + tasks = [ + self.monitor_network(network_name, network_data) + for network_name, network_data in networks.items() + ] + + logger.info(f"Ghost Event Bot started. Monitoring {len(tasks)} networks.") + await asyncio.gather(*tasks) + +if __name__ == "__main__": + TG_TOKEN = os.getenv("TELEGRAM_TOKEN") + CHAT_ID = os.getenv("NOTIFY_CHAT_ID") + THREAD_ID = os.getenv("THREAD_ID") or None + + with open("networks.json", "r") as f: + networks = json.load(f) + + with open("messages.json", "r") as f: + messages = json.load(f) + + bot = EventBot(token=TG_TOKEN, chat_id=CHAT_ID, thread_id=THREAD_ID, messages=messages) + asyncio.run(bot.start(networks)) diff --git a/messages.json b/messages.json new file mode 100644 index 0000000..cde21d2 --- /dev/null +++ b/messages.json @@ -0,0 +1,50 @@ +{ + "Swap": { + "header": "🔄 eCSPR Swap Detected on", + "body": "$eCSPR just changed hands on the DEX!", + "link": "🔗 Check the trade", + "footer": "Time to peek at your ghostDAO profits - spooky gains ahead! 👻📈", + "button_text": "View Current Price", + "button_url": "https://app.dao.ghostchain.io/#/dex/uniswap?from=0x0000000000000000000000000000000000000000&to=" + }, + "Bond": { + "header": "🎉 Bond Purchased on", + "body": "Someone just locked in a bond at a DISCOUNT!", + "link": "🔗 View transaction", + "footer": "Pro tip: Discounts vanish faster than ghosts at sunrise! 👻", + "button_text": "Check Discount", + "button_url": "https://app.dao.ghostchain.io/#/bonds/" + }, + "Stake": { + "header": "🔥 New Staking Action Detected on", + "body": "eCSPR tokens just got staked in the latest block!", + "link": "🔗 View transaction", + "footer": "'To stake or not to stake?' The answer is always STAKE! 👻", + "button_text": "Check Staking APY", + "button_url": "https://app.dao.ghostchain.io/#/stake" + }, + "MarketCreated": { + "header": "✨ Fresh Bond Alert on", + "body": "A new bond is now live with rising discount!", + "link": "🔗 See details here", + "footer": "Claim yours before the Ghosties snatch it up! 👻💰", + "button_text": "View new Bond", + "button_url": "https://app.dao.ghostchain.io/#/bonds/" + }, + "Ghosted": { + "header": "🔀 eCSPR has been GHOSTed on", + "body": "eCSPR has just been bridged in ⛓️➡️👻!", + "link": "🔗 Track the transfer", + "footer": "📢 To all #CASPER Validators: Time to earn bridging fees! 👻💸", + "button_text": "Check Bridge Status", + "button_url": "https://app.dao.ghostchain.io/#/bridge" + }, + "ProposalCreated": { + "header": "🗳 A new proposal just hit the ghostDAO voting floor on", + "body": "Time to make your CSPR count!", + "link": "🔗 Check the proposal", + "footer": "Go cast your vote before the proposal haunts you forever! 👻⚖️", + "button_text": "View Proposal", + "button_url": "https://app.dao.ghostchain.io/#/governance/" + } +} diff --git a/networks.json b/networks.json new file mode 100644 index 0000000..a719fc8 --- /dev/null +++ b/networks.json @@ -0,0 +1,110 @@ +{ + "sepolia": { + "ftso": "0x7ebd1224D36d64eA09312073e60f352d1383801A", + "explorer_tx_link": "https://sepolia.etherscan.io/tx/", + "events": [ + { + "name": "Swap", + "address": "0xCd1505E5d169525e0241c177aF5929A92E02276D", + "topic": "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822" + }, + { + "name": "Bond", + "address": "0x46BF6F7c3e96351eab7542f2B14c9f2ac6d08dF0", + "topic": "0x7880508a48fd3aee88f7e15917d85e39c3ad059e51ad4aca9bb46e7b4938b961" + }, + { + "name": "Stake", + "address": "0xC2C579631Bf6daA93252154080fecfd68c6aa506", + "topic": "0xfb1c60409e7f5b507f77c07b9d283bbd9569bcdc0b505ada7fad911130420ed8" + }, + { + "name": "MarketCreated", + "address": "0x46BF6F7c3e96351eab7542f2B14c9f2ac6d08dF0", + "topic": "0xf5110a61238afa49549d323d552cac783a8c8104bd1bbf42c5b479d60927f033" + }, + { + "name": "Ghosted", + "address": "0xd735cA07984a16911222c08411A80e24EB38869B", + "topic": "0x7ab52ec05c331e6257a3d705d6bea6e4c27277351764ad139209e06b203811a6" + }, + { + "name": "ProposalCreated", + "address": "0xaf7Ad1b83C47405BB9aa96868bCFbb6D65e4C2a1", + "topic": "0x7d84a6263ae0d98d3329bd7b46bb4e8d6f98cd35a7adb45c274c8b7fd5ebd5e0" + } + ] + }, + "mordor": { + "ftso": "0x7ebd1224D36d64eA09312073e60f352d1383801A", + "explorer_tx_link": "https://etc-mordor.blockscout.com/tx/", + "events": [ + { + "name": "Swap", + "address": "0x53B13C4722081c405ce25c7A7629fC326A49a469", + "topic": "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822" + }, + { + "name": "Bond", + "address": "0x46BF6F7c3e96351eab7542f2B14c9f2ac6d08dF0", + "topic": "0x7880508a48fd3aee88f7e15917d85e39c3ad059e51ad4aca9bb46e7b4938b961" + }, + { + "name": "Stake", + "address": "0xC2C579631Bf6daA93252154080fecfd68c6aa506", + "topic": "0xfb1c60409e7f5b507f77c07b9d283bbd9569bcdc0b505ada7fad911130420ed8" + }, + { + "name": "MarketCreated", + "address": "0x46BF6F7c3e96351eab7542f2B14c9f2ac6d08dF0", + "topic": "0xf5110a61238afa49549d323d552cac783a8c8104bd1bbf42c5b479d60927f033" + }, + { + "name": "Ghosted", + "address": "0x4823F1DC785D721eAdD2bD218E1eeD63aF67fBF4", + "topic": "0x7ab52ec05c331e6257a3d705d6bea6e4c27277351764ad139209e06b203811a6" + }, + { + "name": "ProposalCreated", + "address": "0xF950101af53733Ccf9309Ef4CC374B300dd43010", + "topic": "0x7d84a6263ae0d98d3329bd7b46bb4e8d6f98cd35a7adb45c274c8b7fd5ebd5e0" + } + ] + }, + "hoodi": { + "ftso": "0x7ebd1224D36d64eA09312073e60f352d1383801A", + "explorer_tx_link": "https://hoodi.etherscan.io/tx/", + "events": [ + { + "name": "Swap", + "address": "0x53B13C4722081c405ce25c7A7629fC326A49a469", + "topic": "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822" + }, + { + "name": "Bond", + "address": "0x46BF6F7c3e96351eab7542f2B14c9f2ac6d08dF0", + "topic": "0x7880508a48fd3aee88f7e15917d85e39c3ad059e51ad4aca9bb46e7b4938b961" + }, + { + "name": "Stake", + "address": "0xC2C579631Bf6daA93252154080fecfd68c6aa506", + "topic": "0xfb1c60409e7f5b507f77c07b9d283bbd9569bcdc0b505ada7fad911130420ed8" + }, + { + "name": "MarketCreated", + "address": "0x46BF6F7c3e96351eab7542f2B14c9f2ac6d08dF0", + "topic": "0xf5110a61238afa49549d323d552cac783a8c8104bd1bbf42c5b479d60927f033" + }, + { + "name": "Ghosted", + "address": "0x4823F1DC785D721eAdD2bD218E1eeD63aF67fBF4", + "topic": "0x7ab52ec05c331e6257a3d705d6bea6e4c27277351764ad139209e06b203811a6" + }, + { + "name": "ProposalCreated", + "address": "0xF950101af53733Ccf9309Ef4CC374B300dd43010", + "topic": "0x7d84a6263ae0d98d3329bd7b46bb4e8d6f98cd35a7adb45c274c8b7fd5ebd5e0" + } + ] + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a24ecb3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,44 @@ +aiofiles==25.1.0 +aiogram==3.26.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.13.5 +aiosignal==1.4.0 +annotated-types==0.7.0 +async-timeout==5.0.1 +attrs==26.1.0 +bitarray==3.8.1 +certifi==2026.2.25 +charset-normalizer==3.4.7 +ckzg==2.1.7 +cytoolz==1.1.0 +eth-account==0.13.7 +eth-hash==0.8.0 +eth-keyfile==0.8.1 +eth-keys==0.7.0 +eth-rlp==2.2.0 +eth-typing==6.0.0 +eth-utils==6.0.0 +eth_abi==5.2.0 +frozenlist==1.8.0 +hexbytes==1.3.1 +idna==3.11 +magic-filter==1.0.12 +multidict==6.7.1 +parsimonious==0.10.0 +propcache==0.4.1 +pycryptodome==3.23.0 +pydantic==2.12.5 +pydantic_core==2.41.5 +python-dotenv==1.2.2 +pyunormalize==17.0.0 +regex==2026.3.32 +requests==2.33.1 +rlp==4.1.0 +toolz==1.1.0 +types-requests==2.33.0.20260402 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +web3==7.15.0 +websockets==15.0.1 +yarl==1.23.0 diff --git a/rpcs/hoodi.txt b/rpcs/hoodi.txt new file mode 100644 index 0000000..85f1fb0 --- /dev/null +++ b/rpcs/hoodi.txt @@ -0,0 +1,4 @@ +https://rpc.hoodi.ethpandaops.io +https://ethereum-hoodi.gateway.tatum.io +https://0xrpc.io/hoodi +https://rpc.sentio.xyz/hoodi diff --git a/rpcs/mordor.txt b/rpcs/mordor.txt new file mode 100644 index 0000000..2ad22a9 --- /dev/null +++ b/rpcs/mordor.txt @@ -0,0 +1,3 @@ +https://rpc.mordor.etccooperative.org +https://0xrpc.io/mordor +https://geth-mordor.etc-network.info diff --git a/rpcs/sepolia.txt b/rpcs/sepolia.txt new file mode 100644 index 0000000..9f730cf --- /dev/null +++ b/rpcs/sepolia.txt @@ -0,0 +1,5 @@ +https://rpc.sepolia.ethpandaops.io +https://ethereum-sepolia.gateway.tatum.io +https://sepolia.gateway.tenderly.co +https://0xrpc.io/sep +https://rpc.sepolia.ethpandaops.io