Skip to content

Commit 26c759b

Browse files
committed
fix(automations): operate check new feed on cron
1 parent 5197c93 commit 26c759b

File tree

6 files changed

+746
-632
lines changed

6 files changed

+746
-632
lines changed

.github/workflows/automatic-feed-request.yml

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
name: Automatic Feed Request
22

33
on:
4+
schedule:
5+
# Once per day at 03:00 UTC
6+
- cron: "0 3 * * *"
7+
workflow_dispatch:
48
push:
5-
branches:
6-
- main
7-
8-
permissions:
9-
contents: read
10-
issues: write
9+
branches: [main]
1110

1211
jobs:
1312
check_and_report:
1413
name: Run automatic feed request
1514
runs-on: ubuntu-latest
15+
permissions:
16+
contents: read
17+
issues: write
1618
defaults:
1719
run:
1820
working-directory: ./automations
@@ -25,19 +27,12 @@ jobs:
2527
enable-cache: true
2628
- name: Install dependencies
2729
run: uv sync
28-
- name: Generate issues file
30+
- name: Check for new feeds
2931
run: uv run check_new_feed.py
30-
- name: Check if issues file is empty
31-
id: check_file
32-
run: |
33-
if [ -s "automations/issues.md" ]; then
34-
echo "file_is_empty=false" >> $GITHUB_OUTPUT
35-
else
36-
echo "file_is_empty=true" >> $GITHUB_OUTPUT
37-
fi
32+
id: feed
3833
- name: Create issue
3934
uses: JasonEtco/create-an-issue@v2
40-
if: steps.check_file.outputs.file_is_empty == 'false'
35+
if: steps.feed.outcome == 'success' && steps.feed.conclusion != 'neutral'
4136
env:
4237
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4338
with:

automations/check_new_feed.py

Lines changed: 97 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,28 @@
22
import logging
33
import sys
44
from pathlib import Path
5-
from typing import Any
5+
from typing import Any, Final
66

77
import requests
8-
from pycoingecko import CoinGeckoAPI
8+
from pycoingecko import CoinGeckoAPI # pyright: ignore[reportMissingTypeStubs]
9+
from tenacity import retry, stop_after_attempt, wait_exponential_jitter
910
from web3 import Web3
1011
from web3.contract import Contract
1112

1213
# Configuration
13-
RPC_URL = "https://flare-api.flare.network/ext/C/rpc"
14-
REGISTRY_ADDRESS = "0xaD67FE66660Fb8dFE9d6b1b4240d8650e30F6019"
15-
EXPLORER_API_URL = "https://flare-explorer.flare.network/api"
16-
ISSUES_FILE = Path("issues.md")
17-
MAX_MARKET_CAP_RANK = 100
18-
HEADER_TEMPLATE = """---
14+
RPC_URL: Final[str] = "https://flare-api.flare.network/ext/C/rpc"
15+
REGISTRY_ADDRESS: Final[str] = "0xaD67FE66660Fb8dFE9d6b1b4240d8650e30F6019"
16+
EXPLORER_API_URL: Final[str] = "https://flare-explorer.flare.network/api"
17+
ISSUES_FILE: Final[Path] = Path("issues.md")
18+
MAX_MARKET_CAP_RANK: Final[int] = 100
19+
HEADER_TEMPLATE: Final[str] = """---
1920
title: "[auto_req]: Potential New Feeds"
2021
assignees: dineshpinto
2122
labels: "enhancement"
2223
---
2324
"""
25+
26+
# Logging
2427
logging.basicConfig(
2528
encoding="utf-8",
2629
level=logging.INFO,
@@ -29,101 +32,113 @@
2932
logger = logging.getLogger(__name__)
3033

3134

32-
def get_contract_abi(contract_address: str) -> dict[str, Any]:
33-
"""Fetch the ABI for a contract from the Chain Explorer API."""
34-
params = {"module": "contract", "action": "getabi", "address": contract_address}
35-
headers = {"accept": "application/json"}
35+
class ExplorerError(RuntimeError):
36+
"""Raised when the chain explorer cannot provide a contract ABI."""
37+
3638

39+
@retry(stop=stop_after_attempt(3), wait=wait_exponential_jitter(initial=1, max=8))
40+
def _get(
41+
session: requests.Session, **kwargs: dict[str, str] | str
42+
) -> requests.Response:
43+
"""`requests.get` with automatic retry and logging."""
44+
logger.debug("GET %s", kwargs.get("url") or kwargs.get("url"))
45+
resp = session.get(**kwargs, timeout=10) # pyright: ignore[reportArgumentType]
46+
resp.raise_for_status()
47+
return resp
48+
49+
50+
def get_contract_abi(
51+
contract_address: str, session: requests.Session
52+
) -> list[dict[str, Any]]:
53+
"""Return ABI for `contract_address` from the explorer API."""
54+
params = {"module": "contract", "action": "getabi", "address": contract_address}
3755
try:
38-
response = requests.get(
39-
EXPLORER_API_URL, params=params, headers=headers, timeout=10
56+
r = _get(
57+
session,
58+
url=EXPLORER_API_URL,
59+
params=params,
60+
headers={"accept": "application/json"},
4061
)
41-
response.raise_for_status()
42-
result = response.json().get("result")
43-
return json.loads(result)
44-
except (requests.RequestException, ValueError, json.JSONDecodeError):
45-
logger.exception("Error fetching ABI for contract")
46-
sys.exit(1)
62+
return json.loads(r.json()["result"])
63+
except (requests.RequestException, json.JSONDecodeError, KeyError) as exc:
64+
msg = f"Could not fetch ABI for {contract_address}"
65+
raise ExplorerError(msg) from exc
4766

4867

49-
def format_coin_info(coin_data: dict[str, Any]) -> str:
50-
"""Format coin data dictionary to a readable string."""
51-
coin_info = {
52-
"name": coin_data.get("name", "N/A"),
53-
"symbol": coin_data.get("symbol", "N/A"),
54-
"coingecko_id": coin_data.get("id", "N/A"),
55-
"price_change_percentage_24h": coin_data.get("data", {})
56-
.get("price_change_percentage_24h", {})
57-
.get("usd", "N/A"),
58-
"total_volume": coin_data.get("data", {}).get("total_volume", "N/A"),
59-
"coingecko_link": f"https://www.coingecko.com/en/coins/{coin_data.get('id', '')}",
60-
"description": coin_data.get("data", {}).get("content", {}).get("description")
61-
if coin_data.get("data", {}).get("content", {})
62-
else "",
63-
}
68+
def decode_feed_name(feed: bytes) -> str:
69+
"""Convert feed bytes (returned by contract) to a plain symbol."""
70+
return feed[1:].decode().rstrip("\x00").split("/")[0]
6471

65-
return "\n".join(f"{key}: {value}" for key, value in coin_info.items())
6672

73+
def get_current_feeds(contract: Contract) -> set[str]:
74+
"""Return a **set** of existing feed symbols."""
75+
feeds: list[bytes] = contract.functions.fetchAllCurrentFeeds().call()[0]
76+
return {decode_feed_name(f) for f in feeds}
6777

68-
def get_current_feeds(contract: Contract) -> list[str]:
69-
"""Fetch the current block latency feeds from the contract."""
70-
try:
71-
feeds = contract.functions.fetchAllCurrentFeeds().call()
72-
return [
73-
feed[1:].decode("utf-8").rstrip("\x00").split("/")[0] for feed in feeds[0]
74-
]
75-
except Exception:
76-
logger.exception("Error fetching current feeds")
77-
sys.exit(1)
7878

79+
def prettify_coin(coin: dict[str, Any]) -> str:
80+
"""Convert `coin` dict into a readable block of Markdown."""
81+
d = coin.get("data", {})
82+
change_24h = d.get("price_change_percentage_24h", {}).get("usd", "N/A")
83+
total_vol = d.get("total_volume", "N/A")
7984

80-
def write_issues_file(path: Path, header: str, coins: list[dict[str, Any]]) -> None:
81-
"""Write coin data to the issues.md file."""
82-
with path.open("w", encoding="utf-8") as file:
83-
file.write(header)
84-
file.write("Coins matching criteria:\n\n")
85-
for coin in coins:
86-
file.write(f"## {coin['name']}\n")
87-
file.write(format_coin_info(coin))
88-
file.write("\n\n")
89-
logger.info("New feeds written to %s", path)
85+
return (
86+
f"name: {coin.get('name', 'N/A')}\n"
87+
f"symbol: {coin.get('symbol', 'N/A')}\n"
88+
f"coingecko_id: {coin.get('id', 'N/A')}\n"
89+
f"price_change_percentage_24h: {change_24h}\n"
90+
f"total_volume: {total_vol}\n"
91+
f"coingecko_link: https://www.coingecko.com/en/coins/{coin.get('id', '')}\n"
92+
)
9093

9194

92-
if __name__ == "__main__":
95+
def write_issue(coins: list[dict[str, Any]]) -> None:
96+
"""Write the Markdown issue file."""
97+
body = ["Coins matching criteria:\n"]
98+
body += [f"## {c['name']}\n{prettify_coin(c)}\n" for c in coins]
99+
ISSUES_FILE.write_text(HEADER_TEMPLATE + "\n".join(body), encoding="utf-8")
100+
logger.info("Wrote %d coin(s) to %s", len(coins), ISSUES_FILE)
101+
102+
103+
def main() -> None:
104+
cg = CoinGeckoAPI()
105+
session = requests.Session()
106+
93107
w3 = Web3(Web3.HTTPProvider(RPC_URL))
94-
logger.info("Connected to RPC `%s`", RPC_URL)
108+
if not w3.is_connected():
109+
msg = f"Cannot reach RPC at {RPC_URL}"
110+
raise ConnectionError(msg)
111+
logger.info("Connected to RPC %s", RPC_URL)
95112

96-
# Get contract registry
97113
registry = w3.eth.contract(
98114
address=Web3.to_checksum_address(REGISTRY_ADDRESS),
99-
abi=get_contract_abi(REGISTRY_ADDRESS),
115+
abi=get_contract_abi(REGISTRY_ADDRESS, session),
100116
)
101-
102-
# Set up contract
103-
fast_updater_address = registry.functions.getContractAddressByName(
104-
"FastUpdater"
105-
).call()
117+
updater_addr = registry.functions.getContractAddressByName("FastUpdater").call()
106118
fast_updater = w3.eth.contract(
107-
address=Web3.to_checksum_address(fast_updater_address),
108-
abi=get_contract_abi(fast_updater_address),
119+
address=Web3.to_checksum_address(updater_addr),
120+
abi=get_contract_abi(updater_addr, session),
109121
)
110-
logger.info("Connected to FastUpdater contract `%s`", fast_updater_address)
122+
logger.info("FastUpdater contract %s", fast_updater.address)
111123

112-
# Query block latency feeds
113124
current_feeds = get_current_feeds(fast_updater)
114125

115-
# Fetch trending coins
116-
cg = CoinGeckoAPI()
117-
trending = cg.get_search_trending()
118-
119-
# Filter coins based on criteria
120-
selected_coins = [
121-
coin["item"]
122-
for coin in trending["coins"]
123-
if coin["item"].get("market_cap_rank", float("inf")) < MAX_MARKET_CAP_RANK
124-
and coin["item"].get("symbol") not in current_feeds
126+
trending = cg.get_search_trending()["coins"] # pyright: ignore[reportUnknownMemberType]
127+
logger.info("Found potential coins %s", [f"{c['item']['name']}" for c in trending])
128+
candidates = [
129+
c["item"]
130+
for c in trending
131+
if c["item"].get("market_cap_rank", float("inf")) < MAX_MARKET_CAP_RANK
132+
and c["item"]["symbol"] not in current_feeds
125133
]
134+
if not candidates:
135+
sys.exit(78) # Skips GitHub workflow 'if' steps
136+
write_issue(candidates)
126137

127-
# Write results to issues file
128-
if selected_coins:
129-
write_issues_file(ISSUES_FILE, HEADER_TEMPLATE, selected_coins)
138+
139+
if __name__ == "__main__":
140+
try:
141+
main()
142+
except Exception:
143+
logger.exception("Fatal error")
144+
sys.exit(1)

automations/feed_table_generator.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def generate_feed_data(
156156

157157

158158
if __name__ == "__main__":
159-
logging.info("Running Feed Table automation...")
159+
logger.info("Running Feed Table automation...")
160160

161161
w3 = Web3(Web3.HTTPProvider(RPC_URL))
162162
logger.debug("Connected to RPC `%s`", RPC_URL)
@@ -199,7 +199,7 @@ def generate_feed_data(
199199
anchor_data = generate_feed_data(feed_names, anchor_risk, coins_list)
200200
write_data_to_file(ANCHOR_FEEDS_PATH, anchor_data)
201201

202-
logging.info(
202+
logger.info(
203203
"Feed Table automation: Data successfully saved to %s and %s",
204204
BLOCK_LATENCY_FEEDS_PATH,
205205
ANCHOR_FEEDS_PATH,

automations/pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ readme = "README.md"
66
requires-python = ">=3.12"
77
dependencies = [
88
"pycoingecko>=3.1.0",
9+
"tenacity>=9.1.2",
910
"tqdm>=4.67.1",
1011
"web3>=7.2.0",
1112
]
@@ -16,6 +17,13 @@ dev-dependencies = [
1617
"ruff>=0.6.4",
1718
]
1819

20+
[tool.ruff]
21+
target-version = "py312"
22+
1923
[tool.ruff.lint]
2024
select = ["ALL"]
2125
ignore = ["S101", "D", "ISC001", "COM812", "T201", "E501"]
26+
27+
[tool.pyright]
28+
pythonVersion = "3.12"
29+
typeCheckingMode = "strict"

automations/solidity_reference_table_generator.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from web3 import Web3
66

77
logging.basicConfig(level=logging.INFO)
8+
logger = logging.getLogger(__name__)
89

910

1011
NETWORK_RPCS = {
@@ -57,13 +58,13 @@ def get_solidity_reference(
5758
{"name": name, "address": address}
5859
)
5960
except Exception:
60-
logging.exception("Error fetching data from %s", network_name)
61+
logger.exception("Error fetching data from %s", network_name)
6162

6263
return solidity_reference
6364

6465

6566
if __name__ == "__main__":
66-
logging.info("Running Solidity Reference automation...")
67+
logger.info("Running Solidity Reference automation...")
6768

6869
solidity_reference = get_solidity_reference(
6970
NETWORK_RPCS, REGISTRY_ADDRESS, REGISTRY_ABI
@@ -73,6 +74,6 @@ def get_solidity_reference(
7374
with output_file.open("w") as f:
7475
json.dump(solidity_reference, f, indent=4)
7576

76-
logging.info(
77+
logger.info(
7778
"Solidity Reference automation: Data successfully saved to %s", output_file
7879
)

0 commit comments

Comments
 (0)