diff --git a/quantara/web_app/contract_tools/blockchain_call.py b/quantara/web_app/contract_tools/blockchain_call.py index a1cf15c9..34bc3510 100644 --- a/quantara/web_app/contract_tools/blockchain_call.py +++ b/quantara/web_app/contract_tools/blockchain_call.py @@ -107,6 +107,35 @@ async def get_balance( return "0" + async def _get_account_data(self, holder_address: str) -> dict | None: + """ + Fetch full account data from Horizon. + """ + if not holder_address: + return None + url = f"{self.horizon_url.rstrip('/')}/accounts/{holder_address}" + try: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status == 404: + logger.info( + "Account %s not found on Stellar network", + holder_address, + ) + return None + if response.status != 200: + logger.warning( + "Horizon returned %d for %s", response.status, url + ) + return None + return await response.json() + except aiohttp.ClientError as exc: + logger.error("Network error fetching account %s: %s", holder_address, exc) + return None + except (ValueError, KeyError, TypeError) as exc: + logger.error("Data error fetching account %s: %s", holder_address, exc) + return None + async def get_token_balances( self, holder_address: str ) -> dict[str, str]: @@ -116,37 +145,30 @@ async def get_token_balances( :param holder_address: Stellar account public key. :return: dict mapping token symbols to balance strings. """ - balances: dict[str, str] = {} - for token in TokenParams.tokens(): - try: - bal = await self.get_balance( - asset_code=token.asset_code, - holder_address=holder_address, - asset_issuer=getattr(token, "asset_issuer", None), - ) - balances[token.name] = bal - except (aiohttp.ClientError, ValueError, KeyError) as exc: - logger.info("token_balance_fetch_failed", token=token.name, error=str(exc)) - return balances + balances: dict[str, str] = { + token.name: "0" for token in TokenParams.tokens() + } + account = await self._get_account_data(holder_address) + if not account: + return balances + + for balance in account.get("balances", []): + asset_type = balance.get("asset_type", "") + asset_code = balance.get("asset_code", "").lower() + asset_issuer = balance.get("asset_issuer", "") - async def _fetch() -> dict[str, str]: - balances: dict[str, str] = {} for token in TokenParams.tokens(): - try: - bal = await self.get_balance( - asset_code=token.asset_code, - holder_address=holder_address, - asset_issuer=getattr(token, "asset_issuer", None), - ) - balances[token.name] = bal - except (aiohttp.ClientError, ValueError, KeyError) as exc: - logger.info( - "Failed to get balance for %s: %s", token.name, exc - ) - return balances + target_code = token.asset_code.lower() + target_issuer = getattr(token, "asset_issuer", None) + + if target_code == "native" or target_code == "xlm": + if asset_type == "native": + balances[token.name] = str(balance.get("balance", "0")) + elif asset_code == target_code: + if target_issuer is None or asset_issuer == target_issuer: + balances[token.name] = str(balance.get("balance", "0")) - cache_key = f"quantara:balances:{holder_address}" - return await get_cached_or_fetch(cache_key, 60, _fetch) + return balances # ------------------------------------------------------------------ # # Loop liquidity / repay data stubs (pool-agnostic for Soroban) @@ -247,18 +269,36 @@ async def fetch_portfolio(self, contract_address: str) -> dict: :return: dict mapping token keys to balance info. """ results = {} + # Pre-initialize with zero balances for token in TokenParams.tokens(): - try: - balance = await self.get_balance( - asset_code=token.asset_code, - holder_address=contract_address, - ) - results[token.name] = { - "balance": balance, - "decimals": token.decimals, - } - except (aiohttp.ClientError, ValueError, KeyError) as exc: - logger.info("portfolio_balance_fetch_failed", token=token.name, error=str(exc)) + results[token.name] = { + "balance": "0", + "decimals": token.decimals, + } + + account = await self._get_account_data(contract_address) + if not account: + return results + + for balance in account.get("balances", []): + asset_type = balance.get("asset_type", "") + asset_code = balance.get("asset_code", "").lower() + asset_issuer = balance.get("asset_issuer", "") + + for token in TokenParams.tokens(): + target_code = token.asset_code.lower() + target_issuer = getattr(token, "asset_issuer", None) + + if target_code == "native" or target_code == "xlm": + if asset_type == "native": + results[token.name]["balance"] = str( + balance.get("balance", "0") + ) + elif asset_code == target_code: + if target_issuer is None or asset_issuer == target_issuer: + results[token.name]["balance"] = str( + balance.get("balance", "0") + ) return results