walletpress/backend/routers/balance_fetcher.py
cryptorugmunch e13bd4d774
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / security (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / license (push) Waiting to run
CI / ai-review (push) Waiting to run
docs: apply fleet-template (16-artifact scaffold)
Adds missing standard artifacts:
- README.md (if missing)
- AGENTS.md (AI agent contract)
- PLAN.md (current sprint)
- STATUS.md (where we are)
- DEVELOPMENT.md (dev workflow)
- DEPLOYMENT.md (deploy procedure)
- TESTING.md (test strategy)
- DECISIONS.md (ADR index + templates)
- .github/CODEOWNERS
- .github/workflows/ci.yml

Preserves all existing artifacts.

Refs: RugMunchMedia/fleet-template
2026-07-02 02:07:06 +07:00

242 lines
11 KiB
Python

"""Multi-chain balance fetcher using public RPC endpoints.
No API keys required for basic functionality — all data from public nodes.
Supported: EVM (all 23 chains), Solana, TRON, Bitcoin-family (5 chains),
Cosmos-family (4 chains), Substrate (2 chains), Algorand, Stellar, Filecoin.
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from core.config import cfg
logger = logging.getLogger("wp.balances")
EVM_RPC = {k: v for k, v in cfg.rpc_urls().items() if k != "sol" and k != "trx"}
SOLANA_RPC = cfg.rpc_urls().get("sol", "https://api.mainnet-beta.solana.com")
TRON_RPC = cfg.rpc_urls().get("trx", "https://api.trongrid.io")
async def fetch_balance(chain: str, address: str) -> dict[str, Any]:
chain = chain.lower()
if chain in EVM_RPC:
return await _evm_balance(chain, address)
if chain == "sol":
return await _solana_balance(address)
if chain == "trx":
return await _tron_balance(address)
if chain in ("btc", "btc-segwit", "btc-native-segwit", "ltc", "doge", "bch", "dash", "zec"):
return await _btc_family_balance(chain, address)
if chain in ("dot", "ksm"):
return await _substrate_balance(chain, address)
if chain in ("atom", "osmo", "inj", "juno", "sei"):
return await _cosmos_balance(chain, address)
if chain == "algo":
return await _algorand_balance(address)
if chain == "xlm":
return await _stellar_balance(address)
if chain == "fil":
return await _filecoin_balance(address)
if chain == "xrp":
return await _ripple_balance(address)
if chain == "ada":
return await _cardano_balance(address)
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "unsupported", "error": f"No balance API for {chain}"}
async def _evm_balance(chain: str, address: str) -> dict:
rpc_url = EVM_RPC.get(chain)
if not rpc_url:
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "no-rpc"}
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.post(rpc_url, json={"jsonrpc": "2.0", "method": "eth_getBalance", "params": [address, "latest"], "id": 1})
data = r.json()
if "result" in data:
wei = int(data["result"], 16)
eth = wei / 1e18
return {"balance": str(wei), "balance_decimal": round(eth, 6), "balance_usd": 0, "source": f"rpc:{chain}"}
except Exception as e:
logger.warning(f"EVM balance failed {chain}:{address}: {e}")
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": f"rpc:{chain}"}
async def _solana_balance(address: str) -> dict:
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.post(SOLANA_RPC, json={"jsonrpc": "2.0", "id": 1, "method": "getBalance", "params": [address]})
data = r.json()
if "result" in data:
lamports = data["result"]["value"]
sol = lamports / 1e9
return {"balance": str(lamports), "balance_decimal": round(sol, 6), "balance_usd": 0, "source": "rpc:solana"}
except Exception as e:
logger.warning(f"SOL balance failed {address}: {e}")
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "rpc:solana"}
async def _tron_balance(address: str) -> dict:
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.get(f"{TRON_RPC}/v1/accounts/{address}")
data = r.json()
if "data" in data and len(data["data"]) > 0:
raw = int(data["data"][0].get("balance", 0))
trx = raw / 1e6
return {"balance": str(raw), "balance_decimal": round(trx, 6), "balance_usd": 0, "source": "rpc:tron"}
except Exception as e:
logger.warning(f"TRX balance failed {address}: {e}")
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "rpc:tron"}
async def _btc_family_balance(chain: str, address: str) -> dict:
"""Bitcoin-family via blockchain.info (free, no key) and blockchair."""
try:
if chain == "btc":
url = f"https://blockchain.info/balance?active={address}"
else:
explorer_map = {"ltc": "litecoin", "doge": "dogecoin", "bch": "bitcoin-cash", "dash": "dash", "zec": "zcash"}
slug = explorer_map.get(chain, "bitcoin")
url = f"https://api.blockchair.com/{slug}/dashboards/address/{address}?limit=1"
async with httpx.AsyncClient(timeout=8) as c:
r = await c.get(url)
data = r.json()
if chain == "btc" and address in data:
sat = data[address].get("final_balance", 0)
btc = sat / 1e8
return {"balance": str(sat), "balance_decimal": round(btc, 8), "balance_usd": 0, "source": "blockchain.info"}
if "data" in data:
addr_data = data["data"].get(address, {})
raw = int(addr_data.get("address", {}).get("balance", 0))
decimals = {"ltc": 8, "doge": 8, "bch": 8, "dash": 8, "zec": 8}
div = 10 ** decimals.get(chain, 8)
return {"balance": str(raw), "balance_decimal": round(raw / div, 8), "balance_usd": 0, "source": "blockchair"}
except Exception as e:
logger.warning(f"{chain} balance failed {address}: {e}")
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": f"blockchair:{chain}"}
async def _substrate_balance(chain: str, address: str) -> dict:
"""Polkadot/Kusama via Subscan public API."""
subscan_map = {"dot": "polkadot", "ksm": "kusama"}
network = subscan_map.get(chain)
if not network:
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "unsupported"}
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.post(f"https://{network}.api.subscan.io/api/scan/account/tokens", json={"address": address})
data = r.json()
if data.get("code") == 0 and data.get("data"):
for token in data["data"]:
if token.get("symbol", "").upper() == chain.upper():
raw = float(token.get("balance", 0))
decimals = 10 if chain == "dot" else 12
return {"balance": str(int(raw * 10**decimals)), "balance_decimal": round(raw, 4), "balance_usd": 0, "source": "subscan"}
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "subscan"}
except Exception as e:
logger.warning(f"Substrate balance failed {chain}:{address}: {e}")
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "subscan"}
async def _cosmos_balance(chain: str, address: str) -> dict:
"""Cosmos-family via public LCD REST API."""
rpc_map = {"atom": "https://rest.cosmos.directory/cosmoshub", "osmo": "https://rest.cosmos.directory/osmosis",
"inj": "https://rest.cosmos.directory/injective", "juno": "https://rest.cosmos.directory/juno",
"sei": "https://rest.cosmos.directory/sei"}
rpc_url = rpc_map.get(chain)
denom_map = {"atom": "uatom", "osmo": "uosmo", "inj": "inj", "juno": "ujuno", "sei": "usei"}
denom = denom_map.get(chain, f"u{chain}")
if not rpc_url:
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "no-rpc"}
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.get(f"{rpc_url}/cosmos/bank/v1beta1/balances/{address}/by_denom?denom={denom}")
data = r.json()
if "balance" in data:
raw = int(data["balance"].get("amount", "0"))
decimals = 6
return {"balance": str(raw), "balance_decimal": round(raw / 10**decimals, 6), "balance_usd": 0, "source": f"cosmos:{chain}"}
except Exception as e:
logger.warning(f"Cosmos balance failed {chain}:{address}: {e}")
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": f"cosmos:{chain}"}
async def _algorand_balance(address: str) -> dict:
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.get(f"https://algoexplorerapi.io/v2/accounts/{address}")
data = r.json()
if "amount" in data:
raw = int(data["amount"])
algo = raw / 1e6
return {"balance": str(raw), "balance_decimal": round(algo, 6), "balance_usd": 0, "source": "algoexplorer"}
except Exception as e:
logger.warning(f"ALGO balance failed {address}: {e}")
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "algoexplorer"}
async def _stellar_balance(address: str) -> dict:
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.get(f"https://horizon.stellar.org/accounts/{address}")
data = r.json()
if "balances" in data:
for b in data["balances"]:
if b.get("asset_type") == "native":
xlm = float(b["balance"])
raw = int(xlm * 1e7)
return {"balance": str(raw), "balance_decimal": round(xlm, 7), "balance_usd": 0, "source": "stellar"}
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "stellar"}
except Exception as e:
logger.warning(f"XLM balance failed {address}: {e}")
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "stellar"}
async def _filecoin_balance(address: str) -> dict:
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.post("https://api.node.glif.io/rpc/v0", json={
"jsonrpc": "2.0", "id": 1, "method": "Filecoin.WalletBalance", "params": [address]
})
data = r.json()
if "result" in data:
raw = int(data["result"], 16) if data["result"].startswith("0x") else int(data["result"])
fil = raw / 1e18
return {"balance": str(raw), "balance_decimal": round(fil, 6), "balance_usd": 0, "source": "filecoin"}
except Exception as e:
logger.warning(f"FIL balance failed {address}: {e}")
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "filecoin"}
async def _ripple_balance(address: str) -> dict:
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.post("https://s1.ripple.com:51234", json={
"method": "account_info", "params": [{"account": address, "strict": True}]
})
data = r.json()
if data.get("result", {}).get("status") == "success":
drops = int(data["result"]["account_data"]["Balance"])
xrp = drops / 1e6
return {"balance": str(drops), "balance_decimal": round(xrp, 6), "balance_usd": 0, "source": "ripple"}
except Exception as e:
logger.warning(f"XRP balance failed {address}: {e}")
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "ripple"}
async def _cardano_balance(address: str) -> dict:
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.get(f"https://cardanoscan.io/api/address/{address}")
data = r.json()
if "totalBalance" in data:
raw = int(data["totalBalance"])
ada = raw / 1e6
return {"balance": str(raw), "balance_decimal": round(ada, 6), "balance_usd": 0, "source": "cardanoscan"}
except Exception as e:
logger.warning(f"ADA balance failed {address}: {e}")
return {"balance": "0", "balance_decimal": 0, "balance_usd": 0, "source": "cardanoscan"}