11import json
22import logging
3+ import re
34import sys
45from dataclasses import dataclass
56from pathlib import Path
@@ -24,8 +25,21 @@ def get(cls, name: str) -> "NetworkConfig":
2425 return cls .by_name [name ]
2526
2627
28+ # Configuration
29+ ISSUES_FILE : Final [Path ] = Path ("issues.md" )
30+ MAX_MARKET_CAP_RANK : Final [int ] = 100
31+ MIN_USD_VOLUME : Final [int ] = 100_000_000
32+ GITHUB_NEUTRAL_EXIT : Final [int ] = 78
33+ HEADER_TEMPLATE : Final [str ] = """---
34+ title: "[auto_req]: Potential New Feeds"
35+ assignees:
36+ - dineshpinto
37+ labels:
38+ - enhancement
39+ ---
40+ """
41+ _VOLUME_RE = re .compile (r"^\$?\s*([\d,]+(?:\.\d{1,2})?)$" )
2742REGISTRY_ADDRESS : Final [str ] = "0xaD67FE66660Fb8dFE9d6b1b4240d8650e30F6019"
28-
2943NETWORKS : list [NetworkConfig ] = [
3044 NetworkConfig (
3145 name = "FlareMainnet" ,
@@ -48,20 +62,8 @@ def get(cls, name: str) -> "NetworkConfig":
4862 explorer_api_url = "https://coston-explorer.flare.network/api" ,
4963 ),
5064]
51-
5265NetworkConfig .by_name = {net .name : net for net in NETWORKS }
5366
54-
55- # Configuration
56- ISSUES_FILE : Final [Path ] = Path ("issues.md" )
57- MAX_MARKET_CAP_RANK : Final [int ] = 100
58- HEADER_TEMPLATE : Final [str ] = """---
59- title: "[auto_req]: Potential New Feeds"
60- assignees: dineshpinto
61- labels: "enhancement"
62- ---
63- """
64-
6567# Logging
6668logging .basicConfig (
6769 encoding = "utf-8" ,
@@ -76,12 +78,10 @@ class ExplorerError(RuntimeError):
7678
7779
7880@retry (stop = stop_after_attempt (3 ), wait = wait_exponential_jitter (initial = 1 , max = 8 ))
79- def _get (
80- session : requests .Session , ** kwargs : dict [str , str ] | str
81- ) -> requests .Response :
81+ def _get (session : requests .Session , url : str , ** kwargs : Any ) -> requests .Response : # noqa: ANN401
8282 """`requests.get` with automatic retry and logging."""
83- logger .debug ("GET %s" , kwargs . get ( " url" ) or kwargs .get ("url " ))
84- resp = session .get (** kwargs , timeout = 10 ) # pyright: ignore[reportArgumentType]
83+ logger .debug ("GET %s params=%s " , url , kwargs .get ("params " ))
84+ resp = session .get (url , timeout = 10 , ** kwargs )
8585 resp .raise_for_status ()
8686 return resp
8787
@@ -104,83 +104,135 @@ def get_contract_abi(
104104 raise ExplorerError (msg ) from exc
105105
106106
107- def decode_feed_name (feed : bytes ) -> str :
107+ def _decode_feed_name (feed : bytes ) -> str :
108108 """Convert feed bytes (returned by contract) to a plain symbol."""
109- return feed [1 :].decode ().rstrip ("\x00 " ).split ("/" )[0 ]
109+
110+ if not feed :
111+ return ""
112+ raw = feed [1 :]
113+ return raw .decode (errors = "ignore" ).rstrip ("\x00 " ).split ("/" )[0 ]
110114
111115
112116def get_current_feeds (contract : Contract ) -> set [str ]:
113- """Return a **set** of existing feed symbols."""
114117 feeds : list [bytes ] = contract .functions .fetchAllCurrentFeeds ().call ()[0 ]
115- return {decode_feed_name ( f ) for f in feeds }
118+ return {_decode_feed_name ( f ). lower ( ) for f in feeds }
116119
117120
118121def prettify_coin (coin : dict [str , Any ]) -> str :
119122 """Convert `coin` dict into a readable block of Markdown."""
120123 d = coin .get ("data" , {})
121- change_24h = d .get ("price_change_percentage_24h" , {}).get ("usd" , "N/A" )
124+ pct_24 = d .get ("price_change_percentage_24h" , {}).get ("usd" )
125+ change_24h = f"{ round (pct_24 , 2 )} " if isinstance (pct_24 , (int , float )) else "N/A"
122126 total_vol = d .get ("total_volume" , "N/A" )
123127
124128 return (
125- f"name : { coin .get ('name' , 'N/A' )} \n "
126- f"symbol : { coin .get ('symbol' , 'N/A' )} \n "
127- f"coingecko_id : { coin .get ('id' , 'N/A' )} \n "
128- f"price_change_percentage_24h : { change_24h } \n "
129- f"total_volume : { total_vol } \n "
130- f"coingecko_link : https://www.coingecko.com/en/coins/{ coin .get ('id' , '' )} \n "
129+ f"Name : { coin .get ('name' , 'N/A' )} \n "
130+ f"Symbol : { coin .get ('symbol' , 'N/A' )} \n "
131+ f"Coingecko ID : { coin .get ('id' , 'N/A' )} \n "
132+ f"24h Price Change : { change_24h } % \n "
133+ f"Total Volume : { total_vol } \n "
134+ f"Link : https://www.coingecko.com/en/coins/{ coin .get ('id' , '' )} \n "
131135 )
132136
133137
134138def parse_volume (vol_str : str ) -> int :
135- """Parse volume string and return it as an integer."""
136- if not vol_str [0 ] == "$" :
137- raise ValueError (f"Invalid volume string: { vol_str } " )
138- return int (vol_str .strip ("$" ).replace ("," , "" ))
139+ """Parse a USD volume string like '$123,456.78' -> 123456 (floor)."""
140+ m = _VOLUME_RE .match (vol_str .strip ())
141+ if not m :
142+ msg = f"Invalid volume string: { vol_str !r} "
143+ raise ValueError (msg )
144+ num = m .group (1 ).replace ("," , "" )
145+ return int (float (num ))
139146
140147
141148def write_issue (coins : list [dict [str , Any ]]) -> None :
142- """Write the Markdown issue file."""
143- body = ["Coins matching criteria:\n " ]
144- body += [f"## { c ['name' ]} \n { prettify_coin (c )} \n " for c in coins ]
145- ISSUES_FILE .write_text (HEADER_TEMPLATE + "\n " .join (body ), encoding = "utf-8" )
149+ lines = [
150+ "Coins matching [FIP.08](https://proposals.flare.network/FIP/FIP_8.html) criteria:\n "
151+ ]
152+ lines += [f"## { c .get ('name' , 'N/A' )} \n { prettify_coin (c )} \n " for c in coins ]
153+ content = HEADER_TEMPLATE + "\n " .join (lines )
154+ tmp = ISSUES_FILE .with_suffix (".tmp" )
155+ tmp .write_text (
156+ content if content .endswith ("\n " ) else content + "\n " , encoding = "utf-8"
157+ )
158+ tmp .replace (ISSUES_FILE )
146159 logger .info ("Wrote %d coin(s) to %s" , len (coins ), ISSUES_FILE )
147160
148161
162+ @retry (stop = stop_after_attempt (3 ), wait = wait_exponential_jitter (initial = 1 , max = 8 ))
163+ def get_trending (cg : CoinGeckoAPI ) -> list [dict [str , Any ]]:
164+ data = cg .get_search_trending () # pyright: ignore[reportUnknownMemberType]
165+ return data .get ("coins" , [])
166+
167+
168+ def _safe_rank (c : dict [str , Any ]) -> float :
169+ return c .get ("item" , {}).get ("market_cap_rank" ) or float ("inf" )
170+
171+
172+ def _safe_vol (c : dict [str , Any ]) -> int :
173+ v = c .get ("item" , {}).get ("data" , {}).get ("total_volume" )
174+ return parse_volume (v ) if isinstance (v , str ) else 0
175+
176+
177+ def filter_candidates (
178+ trending : list [dict [str , Any ]],
179+ current_feeds : set [str ],
180+ max_mcap_rank : int ,
181+ min_volume : int ,
182+ ) -> list [dict [str , Any ]]:
183+ return [
184+ c ["item" ]
185+ for c in trending
186+ if _safe_rank (c ) < max_mcap_rank
187+ and _safe_vol (c ) > min_volume
188+ and c .get ("item" , {}).get ("symbol" , "" ).lower () not in current_feeds
189+ ]
190+
191+
149192def main () -> None :
150193 cg = CoinGeckoAPI ()
151- session = requests .Session ()
152194
153195 rpc_url = NetworkConfig .get ("FlareMainnet" ).rpc_url
154- w3 = Web3 (Web3 .HTTPProvider (rpc_url ))
196+ w3 = Web3 (Web3 .HTTPProvider (rpc_url , request_kwargs = { "timeout" : 10 } ))
155197 if not w3 .is_connected ():
156198 msg = f"Cannot reach RPC at { rpc_url } "
157199 raise ConnectionError (msg )
158- logger .info ("Connected to RPC %s" , rpc_url )
200+ logger .info ("Connected to %s" , rpc_url , extra = { "network" : "FlareMainnet" } )
159201
160- registry = w3 .eth .contract (
161- address = Web3 .to_checksum_address (REGISTRY_ADDRESS ),
162- abi = get_contract_abi (REGISTRY_ADDRESS , session ),
163- )
164- updater_addr = registry .functions .getContractAddressByName ("FastUpdater" ).call ()
165- fast_updater = w3 .eth .contract (
166- address = Web3 .to_checksum_address (updater_addr ),
167- abi = get_contract_abi (updater_addr , session ),
168- )
169- logger .info ("FastUpdater contract %s" , fast_updater .address )
202+ with requests .Session () as session :
203+ registry = w3 .eth .contract (
204+ address = Web3 .to_checksum_address (REGISTRY_ADDRESS ),
205+ abi = get_contract_abi (REGISTRY_ADDRESS , session ),
206+ )
207+ updater_addr = registry .functions .getContractAddressByName ("FastUpdater" ).call ()
208+ fast_updater = w3 .eth .contract (
209+ address = Web3 .to_checksum_address (updater_addr ),
210+ abi = get_contract_abi (updater_addr , session ),
211+ )
212+ logger .info ("FastUpdater %s" , fast_updater .address )
170213
171214 current_feeds = get_current_feeds (fast_updater )
215+ logger .info ("Existing feeds: %d" , len (current_feeds ))
216+
217+ trending = get_trending (cg )
218+ logger .info (
219+ "Found potential coins %s" ,
220+ [c .get ("item" , {}).get ("name" , "N/A" ) for c in trending ],
221+ )
222+
223+ candidates = sorted (
224+ filter_candidates (
225+ trending ,
226+ current_feeds ,
227+ max_mcap_rank = MAX_MARKET_CAP_RANK ,
228+ min_volume = MIN_USD_VOLUME ,
229+ ),
230+ key = lambda x : (x .get ("market_cap_rank" ) or float ("inf" ), x .get ("name" , "" )),
231+ )
232+ logger .info ("Filtered candidates: %s" , [c ["name" ] for c in candidates ])
172233
173- trending = cg .get_search_trending ()["coins" ] # pyright: ignore[reportUnknownMemberType]
174- logger .info ("Found potential coins %s" , [f"{ c ['item' ]['name' ]} " for c in trending ])
175- candidates = [
176- c ["item" ]
177- for c in trending
178- if c ["item" ].get ("market_cap_rank" , float ("inf" )) < MAX_MARKET_CAP_RANK
179- and parse_volume (c ["item" ]["data" ]["total_volume" ]) > 100_000_000
180- and c ["item" ]["symbol" ] not in current_feeds
181- ]
182234 if not candidates :
183- sys .exit (78 ) # Skips GitHub workflow 'if' steps
235+ sys .exit (GITHUB_NEUTRAL_EXIT )
184236 write_issue (candidates )
185237
186238
0 commit comments