From 15c06fa9cc2e9a5d6bfaebf4515b396a785158bc Mon Sep 17 00:00:00 2001 From: Noloquideus Date: Wed, 10 Jun 2026 18:21:23 +0300 Subject: [PATCH] feat: add wallets balance --- pyproject.toml | 1 + src/application/commands/__init__.py | 1 + .../commands/organization_wallet_commands.py | 15 + src/infrastructure/config/settings.py | 33 ++ .../wallet_balances/__init__.py | 4 + src/infrastructure/wallet_balances/service.py | 373 ++++++++++++++++++ .../wallet_balances/token_registry.py | 105 +++++ src/presentation/dependencies/commands.py | 13 + src/presentation/routing/organizations.py | 13 + src/presentation/schemas/organization.py | 26 ++ src/presentation/serializers/organization.py | 38 ++ 11 files changed, 622 insertions(+) create mode 100644 src/infrastructure/wallet_balances/__init__.py create mode 100644 src/infrastructure/wallet_balances/service.py create mode 100644 src/infrastructure/wallet_balances/token_registry.py diff --git a/pyproject.toml b/pyproject.toml index 24be3e1..e8c4ebb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "fastapi==0.128.7", "granian==2.6.1", "hvac==2.4.0", + "httpx==0.28.1", "itsdangerous==2.2.0", "pydantic-settings==2.12.0", "python-jose==3.5.0", diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index 01f0c16..d8ffd8b 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -2,6 +2,7 @@ from src.application.commands.organization_commands import GetOrganizationComman from src.application.commands.organization_wallet_commands import ( GetOrganizationMnemonicCommand, GetOrganizationSecretKeysCommand, + GetOrganizationWalletBalancesCommand, ListOrganizationWalletsCommand, ) from src.application.commands.purchase_request_commands import ( diff --git a/src/application/commands/organization_wallet_commands.py b/src/application/commands/organization_wallet_commands.py index cc3bb68..e687b7a 100644 --- a/src/application/commands/organization_wallet_commands.py +++ b/src/application/commands/organization_wallet_commands.py @@ -13,6 +13,8 @@ from src.infrastructure.crypto.wallet_crypto import ( is_crypto_ready, ) from src.infrastructure.database.decorators import transactional +from src.infrastructure.wallet_balances import WalletBalanceService +from src.infrastructure.wallet_balances.service import WalletBalance class ListOrganizationWalletsCommand: @@ -26,6 +28,19 @@ class ListOrganizationWalletsCommand: return await self._unit_of_work.organization_wallet_repository.list_by_organization(org.id) +class GetOrganizationWalletBalancesCommand: + def __init__(self, unit_of_work: IUnitOfWork, balance_service: WalletBalanceService, logger: ILogger): + self._unit_of_work = unit_of_work + self._balance_service = balance_service + self._logger = logger + + async def __call__(self, user_id: str) -> list[WalletBalance]: + async with self._unit_of_work: + org = await require_legal_entity(user_id, self._unit_of_work) + wallets = await self._unit_of_work.organization_wallet_repository.list_by_organization(org.id) + return await self._balance_service.get_wallet_balances(wallets) + + class GetOrganizationMnemonicCommand: def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): self._unit_of_work = unit_of_work diff --git a/src/infrastructure/config/settings.py b/src/infrastructure/config/settings.py index 1d05c0c..5a4fd36 100644 --- a/src/infrastructure/config/settings.py +++ b/src/infrastructure/config/settings.py @@ -74,6 +74,14 @@ class Settings(BaseSettings): JWT_ALGORITHM: str = 'RS256' JWT_KEYS_REFRESH_SECONDS: int = 3600 + ETH_RPC_URL: str = 'https://ethereum-rpc.publicnode.com' + BSC_RPC_URL: str = 'https://bsc-dataseed.binance.org' + SOL_RPC_URL: str = 'https://api.mainnet-beta.solana.com' + TRON_API_KEY: str | None = None + COINGECKO_API_KEY: str | None = None + JUPITER_API_KEY: str | None = None + USDT_CONTRACT_ADDRESS: str | None = None + LOG_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO' LOG_FORMAT: Literal['JSON', 'TEXT'] = 'JSON' @@ -121,6 +129,31 @@ class Settings(BaseSettings): if kv(db, 'PASSWORD', 'password') is not None: object.__setattr__(self, 'DATABASE_PASSWORD', str(kv(db, 'PASSWORD', 'password'))) + crypto = client.read_secret_optional(self.VAULT_CRYPTO_MASTER_KEY_PATH) + if crypto: + eth_rpc_url = kv(crypto, 'eth_rpc_url', 'ETH_RPC_URL', 'etc_rpc_url', 'ETC_RPC_URL') + bsc_rpc_url = kv(crypto, 'bsc_rpc_url', 'BSC_RPC_URL') + sol_rpc_url = kv(crypto, 'sol_rpc_url', 'SOL_RPC_URL') + tron_api_key = kv(crypto, 'tron_api_key', 'TRON_API_KEY') + coingecko_api_key = kv(crypto, 'coingecko_api_key', 'COINGECKO_API_KEY') + jupiter_api_key = kv(crypto, 'jupiter_api_key', 'JUPITER_API_KEY') + usdt_contract_address = kv(crypto, 'usdt_contract_address', 'USDT_CONTRACT_ADDRESS') + + if eth_rpc_url is not None: + object.__setattr__(self, 'ETH_RPC_URL', str(eth_rpc_url).strip()) + if bsc_rpc_url is not None: + object.__setattr__(self, 'BSC_RPC_URL', str(bsc_rpc_url).strip()) + if sol_rpc_url is not None: + object.__setattr__(self, 'SOL_RPC_URL', str(sol_rpc_url).strip()) + if tron_api_key is not None: + object.__setattr__(self, 'TRON_API_KEY', str(tron_api_key).strip() or None) + if coingecko_api_key is not None: + object.__setattr__(self, 'COINGECKO_API_KEY', str(coingecko_api_key).strip() or None) + if jupiter_api_key is not None: + object.__setattr__(self, 'JUPITER_API_KEY', str(jupiter_api_key).strip() or None) + if usdt_contract_address is not None: + object.__setattr__(self, 'USDT_CONTRACT_ADDRESS', str(usdt_contract_address).strip() or None) + csrf = client.read_secret_optional(self.VAULT_CSRF_SECRET_PATH) if csrf and kv(csrf, 'KEY', 'key') is not None: key = str(kv(csrf, 'KEY', 'key')) diff --git a/src/infrastructure/wallet_balances/__init__.py b/src/infrastructure/wallet_balances/__init__.py new file mode 100644 index 0000000..21f1aba --- /dev/null +++ b/src/infrastructure/wallet_balances/__init__.py @@ -0,0 +1,4 @@ +from src.infrastructure.wallet_balances.service import WalletBalanceService + + +__all__ = ['WalletBalanceService'] diff --git a/src/infrastructure/wallet_balances/service.py b/src/infrastructure/wallet_balances/service.py new file mode 100644 index 0000000..e8f4c23 --- /dev/null +++ b/src/infrastructure/wallet_balances/service.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import asyncio +import hashlib +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from typing import Any + +import httpx + +from src.infrastructure.config import settings +from src.infrastructure.wallet_balances.token_registry import ( + NATIVE_DECIMALS, + NATIVE_SYMBOLS, + get_coingecko_id, + get_evm_tokens, + get_sol_tokens, + get_trx_tokens, +) + +TIMEOUT_SECONDS = 15 +BLOCKSTREAM_URL = 'https://blockstream.info/api' +TRONGRID_URL = 'https://api.trongrid.io' +COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price' +BALANCE_OF_SELECTOR = '70a08231' +BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + + +@dataclass(frozen=True, slots=True) +class FormattedAmount: + raw: str + formatted: str + decimals: int + usd_price: Decimal | None = None + usd_value: Decimal | None = None + + +@dataclass(frozen=True, slots=True) +class WalletBalance: + wallet_id: str + chain: str + address: str + derivation_path: str + native_symbol: str + native: FormattedAmount + tokens: dict[str, FormattedAmount] + total_usd: Decimal | None + error: str | None = None + + +class WalletBalanceService: + async def get_wallet_balances(self, wallets) -> list[WalletBalance]: + prices = await self._get_prices(wallets) + results = await asyncio.gather( + *(self._get_wallet_balance(wallet, prices) for wallet in wallets), + return_exceptions=True, + ) + balances: list[WalletBalance] = [] + for wallet, result in zip(wallets, results, strict=False): + if isinstance(result, Exception): + balances.append(self._failed_wallet(wallet, str(result))) + else: + balances.append(result) + return balances + + async def _get_wallet_balance(self, wallet, prices: dict[str, Decimal | None]) -> WalletBalance: + chain = wallet.chain.upper() + if chain == 'BTC': + native_raw, token_raw = await self._btc_balance(wallet.address) + elif chain == 'ETH': + native_raw, token_raw = await self._evm_balance( + settings.ETH_RPC_URL, + wallet.address, + get_evm_tokens(chain), + ) + elif chain == 'BSC': + native_raw, token_raw = await self._evm_balance( + settings.BSC_RPC_URL, + wallet.address, + get_evm_tokens(chain), + ) + elif chain == 'TRX': + native_raw, token_raw = await self._trx_balance(wallet.address) + elif chain == 'SOL': + native_raw, token_raw = await self._sol_balance(wallet.address) + else: + raise ValueError(f'Unsupported chain: {wallet.chain}') + + native = self._amount(native_raw, NATIVE_DECIMALS[chain], prices.get(f'{chain}:{NATIVE_SYMBOLS[chain]}')) + tokens = self._token_amounts(chain, token_raw, prices) + total_usd = self._total_usd(native, tokens) + return WalletBalance( + wallet_id=wallet.id, + chain=chain, + address=wallet.address, + derivation_path=wallet.derivation_path, + native_symbol=NATIVE_SYMBOLS[chain], + native=native, + tokens=tokens, + total_usd=total_usd, + ) + + async def _btc_balance(self, address: str) -> tuple[str, dict[str, str]]: + data = await self._get_json(f'{BLOCKSTREAM_URL}/address/{address}') + stats = data.get('chain_stats') or {} + funded = int(stats.get('funded_txo_sum') or 0) + spent = int(stats.get('spent_txo_sum') or 0) + return str(funded - spent), {} + + async def _evm_balance(self, rpc_url: str, address: str, tokens: list[dict]) -> tuple[str, dict[str, str]]: + native_result = await self._rpc(rpc_url, 'eth_getBalance', [address, 'latest']) + native = self._hex_to_int_string(native_result) + token_items = await asyncio.gather( + *(self._evm_token_balance(rpc_url, address, token) for token in tokens), + return_exceptions=True, + ) + result: dict[str, str] = {} + for token, item in zip(tokens, token_items, strict=False): + result[token['symbol']] = '0' if isinstance(item, Exception) else item + return native, result + + async def _evm_token_balance(self, rpc_url: str, address: str, token: dict) -> str: + data = '0x' + BALANCE_OF_SELECTOR + address.lower().removeprefix('0x').rjust(64, '0') + result = await self._rpc(rpc_url, 'eth_call', [{'to': token['contract'], 'data': data}, 'latest']) + return self._hex_to_int_string(result) + + async def _trx_balance(self, address: str) -> tuple[str, dict[str, str]]: + headers = self._tron_headers() + account = await self._get_json(f'{TRONGRID_URL}/v1/accounts/{address}', headers=headers) + trx = str(((account.get('data') or [{}])[0] or {}).get('balance') or 0) + address_hex = self._tron_address_to_hex(address).rjust(64, '0') + token_config = self._tokens_for_chain('TRX') + token_items = await asyncio.gather( + *(self._trx_token_balance(address, address_hex, token, headers) for token in token_config), + return_exceptions=True, + ) + tokens: dict[str, str] = {} + for token, item in zip(token_config, token_items, strict=False): + tokens[token['symbol']] = '0' if isinstance(item, Exception) else item + return trx, tokens + + async def _trx_token_balance(self, address: str, address_hex: str, token: dict, headers: dict[str, str]) -> str: + data = await self._post_json( + f'{TRONGRID_URL}/wallet/triggerconstantcontract', + { + 'owner_address': address, + 'contract_address': token['contract'], + 'function_selector': 'balanceOf(address)', + 'parameter': address_hex, + 'visible': True, + }, + headers={**headers, 'Content-Type': 'application/json'}, + ) + value = (data.get('constant_result') or [None])[0] + if not value or set(value) == {'0'}: + return '0' + return str(int(value, 16)) + + async def _sol_balance(self, address: str) -> tuple[str, dict[str, str]]: + native_data = await self._rpc(settings.SOL_RPC_URL, 'getBalance', [address]) + native = str((native_data or {}).get('value') or 0) + tokens = {token['symbol']: '0' for token in get_sol_tokens()} + try: + accounts = await self._rpc( + settings.SOL_RPC_URL, + 'getTokenAccountsByOwner', + [ + address, + {'programId': 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'}, + {'encoding': 'jsonParsed'}, + ], + ) + known = {token['mint']: token['symbol'] for token in get_sol_tokens()} + for item in accounts.get('value') or []: + info = (((item.get('account') or {}).get('data') or {}).get('parsed') or {}).get('info') or {} + mint = info.get('mint') + amount = ((info.get('tokenAmount') or {}).get('amount')) or '0' + if mint in known: + symbol = known[mint] + tokens[symbol] = str(int(tokens[symbol]) + int(amount)) + except Exception: + pass + return native, tokens + + async def _get_prices(self, wallets) -> dict[str, Decimal | None]: + pairs: dict[str, str] = {} + for wallet in wallets: + chain = wallet.chain.upper() + symbol = NATIVE_SYMBOLS.get(chain) + coin_id = get_coingecko_id(chain, symbol or chain) + if symbol and coin_id: + pairs[f'{chain}:{symbol}'] = coin_id + for token in self._tokens_for_chain(chain): + coin_id = get_coingecko_id(chain, token['symbol']) + if coin_id: + pairs[f'{chain}:{token['symbol']}'] = coin_id + if not pairs: + return {} + + try: + response = await self._get_json( + COINGECKO_URL, + params={ + 'ids': ','.join(sorted(set(pairs.values()))), + 'vs_currencies': 'usd', + }, + headers=self._coingecko_headers(), + timeout=5, + ) + except Exception: + return {key: None for key in pairs} + + return { + key: self._decimal_or_none((response.get(coin_id) or {}).get('usd')) + for key, coin_id in pairs.items() + } + + def _token_amounts( + self, + chain: str, + raw: dict[str, str], + prices: dict[str, Decimal | None], + ) -> dict[str, FormattedAmount]: + decimals = {token['symbol']: token['decimals'] for token in self._tokens_for_chain(chain)} + return { + symbol: self._amount(value, decimals.get(symbol, 0), prices.get(f'{chain}:{symbol}')) + for symbol, value in raw.items() + } + + def _amount(self, raw: str, decimals: int, usd_price: Decimal | None) -> FormattedAmount: + formatted = self._format_units(raw, decimals) + usd_value = None + if usd_price is not None: + usd_value = self._round_usd(Decimal(formatted) * usd_price) + return FormattedAmount( + raw=raw, + formatted=formatted, + decimals=decimals, + usd_price=usd_price, + usd_value=usd_value, + ) + + def _total_usd(self, native: FormattedAmount, tokens: dict[str, FormattedAmount]) -> Decimal | None: + values = [native.usd_value, *(token.usd_value for token in tokens.values())] + present = [value for value in values if value is not None] + if not present: + return None + return self._round_usd(sum(present, Decimal('0'))) + + def _failed_wallet(self, wallet, error: str) -> WalletBalance: + chain = wallet.chain.upper() + native = FormattedAmount( + raw='0', + formatted='0', + decimals=NATIVE_DECIMALS.get(chain, 0), + ) + return WalletBalance( + wallet_id=wallet.id, + chain=chain, + address=wallet.address, + derivation_path=wallet.derivation_path, + native_symbol=NATIVE_SYMBOLS.get(chain, chain), + native=native, + tokens={}, + total_usd=None, + error=error, + ) + + async def _rpc(self, url: str, method: str, params: list[Any]) -> Any: + payload = { + 'jsonrpc': '2.0', + 'id': 1, + 'method': method, + 'params': params, + } + response = await self._post_json(url, payload) + if response.get('error'): + raise RuntimeError(str(response['error'])) + return response.get('result') + + async def _get_json( + self, + url: str, + *, + params: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + timeout: int = TIMEOUT_SECONDS, + ) -> Any: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get(url, params=params, headers=headers) + response.raise_for_status() + return response.json() + + async def _post_json( + self, + url: str, + payload: dict[str, Any], + *, + headers: dict[str, str] | None = None, + timeout: int = TIMEOUT_SECONDS, + ) -> Any: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post(url, json=payload, headers=headers) + response.raise_for_status() + return response.json() + + def _tokens_for_chain(self, chain: str) -> list[dict]: + if chain in {'ETH', 'BSC'}: + return get_evm_tokens(chain) + if chain == 'TRX': + tokens = [dict(token) for token in get_trx_tokens()] + if settings.USDT_CONTRACT_ADDRESS: + for token in tokens: + if token['symbol'] == 'USDT': + token['contract'] = settings.USDT_CONTRACT_ADDRESS + return tokens + if chain == 'SOL': + return get_sol_tokens() + return [] + + def _format_units(self, raw: str, decimals: int) -> str: + if not raw or not raw.lstrip('-').isdigit(): + return '0' + if decimals == 0: + return raw + negative = raw.startswith('-') + value = raw[1:] if negative else raw + padded = value.rjust(decimals + 1, '0') + whole = padded[:-decimals] + fraction = padded[-decimals:].rstrip('0') + result = f'{whole}.{fraction}' if fraction else whole + return f'-{result}' if negative else result + + def _round_usd(self, value: Decimal) -> Decimal: + return value.quantize(Decimal('0.00000001')) + + def _decimal_or_none(self, value: Any) -> Decimal | None: + try: + return Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return None + + def _hex_to_int_string(self, value: str | None) -> str: + if not value: + return '0' + return str(int(value, 16)) + + def _tron_headers(self) -> dict[str, str]: + headers = {'Accept': 'application/json'} + if settings.TRON_API_KEY: + headers['TRON-PRO-API-KEY'] = settings.TRON_API_KEY + return headers + + def _coingecko_headers(self) -> dict[str, str]: + headers = {'Accept': 'application/json'} + if settings.COINGECKO_API_KEY: + headers['x-cg-demo-api-key'] = settings.COINGECKO_API_KEY + return headers + + def _tron_address_to_hex(self, address: str) -> str: + num = 0 + for char in address: + index = BASE58_ALPHABET.find(char) + if index == -1: + raise ValueError('Invalid TRON address') + num = num * 58 + index + raw = num.to_bytes(25, 'big') + payload = raw[:-4] + checksum = raw[-4:] + expected = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4] + if checksum != expected or payload[0] != 0x41: + raise ValueError('Invalid TRON address') + return payload[1:].hex() diff --git a/src/infrastructure/wallet_balances/token_registry.py b/src/infrastructure/wallet_balances/token_registry.py new file mode 100644 index 0000000..4aad4af --- /dev/null +++ b/src/infrastructure/wallet_balances/token_registry.py @@ -0,0 +1,105 @@ +from __future__ import annotations + + +ChainCode = str + + +NATIVE_DECIMALS: dict[ChainCode, int] = { + 'ETH': 18, + 'BSC': 18, + 'BTC': 8, + 'TRX': 6, + 'SOL': 9, +} + + +NATIVE_SYMBOLS: dict[ChainCode, str] = { + 'BTC': 'BTC', + 'ETH': 'ETH', + 'BSC': 'BNB', + 'TRX': 'TRX', + 'SOL': 'SOL', +} + + +NATIVE_COINGECKO_IDS: dict[ChainCode, str] = { + 'BTC': 'bitcoin', + 'ETH': 'ethereum', + 'BSC': 'binancecoin', + 'TRX': 'tron', + 'SOL': 'solana', +} + + +ETH_TOKENS = [ + {'symbol': 'USDT', 'contract': '0xdAC17F958D2ee523a2206206994597C13D831ec7', 'decimals': 6, 'coingecko_id': 'tether'}, + {'symbol': 'USDC', 'contract': '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 'decimals': 6, 'coingecko_id': 'usd-coin'}, + {'symbol': 'DAI', 'contract': '0x6B175474E89094C44Da98b954EedeAC495271d0F', 'decimals': 18, 'coingecko_id': 'dai'}, + {'symbol': 'WBTC', 'contract': '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', 'decimals': 8, 'coingecko_id': 'wrapped-bitcoin'}, + {'symbol': 'LINK', 'contract': '0x514910771AF9Ca656af840dff83E8264EcF986CA', 'decimals': 18, 'coingecko_id': 'chainlink'}, + {'symbol': 'UNI', 'contract': '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', 'decimals': 18, 'coingecko_id': 'uniswap'}, +] + + +BSC_TOKENS = [ + {'symbol': 'USDT', 'contract': '0x55d398326f99059fF775485246999027B3197955', 'decimals': 18, 'coingecko_id': 'tether'}, + {'symbol': 'USDC', 'contract': '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', 'decimals': 18, 'coingecko_id': 'usd-coin'}, + {'symbol': 'DOGE', 'contract': '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', 'decimals': 8, 'coingecko_id': 'dogecoin'}, + {'symbol': 'WBNB', 'contract': '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', 'decimals': 18, 'coingecko_id': 'wbnb'}, + {'symbol': 'BUSD', 'contract': '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', 'decimals': 18, 'coingecko_id': 'binance-usd'}, +] + + +TRX_TOKENS = [ + {'symbol': 'USDT', 'contract': 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', 'decimals': 6, 'coingecko_id': 'tether'}, +] + + +SOL_TOKENS = [ + {'symbol': 'USDT', 'mint': 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', 'decimals': 6, 'coingecko_id': 'tether'}, + {'symbol': 'USDC', 'mint': 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', 'decimals': 6, 'coingecko_id': 'usd-coin'}, + {'symbol': 'PUMP', 'mint': 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', 'decimals': 6, 'coingecko_id': 'pump-fun'}, + {'symbol': 'JUP', 'mint': 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', 'decimals': 6, 'coingecko_id': 'jupiter-exchange-solana'}, + {'symbol': 'WIF', 'mint': 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', 'decimals': 6, 'coingecko_id': 'dogwifcoin'}, + {'symbol': 'POPCAT', 'mint': '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', 'decimals': 9, 'coingecko_id': 'popcat'}, + {'symbol': 'TRUMP', 'mint': '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', 'decimals': 6, 'coingecko_id': 'official-trump'}, + {'symbol': 'PYTH', 'mint': 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', 'decimals': 6, 'coingecko_id': 'pyth-network'}, + {'symbol': 'JTO', 'mint': 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', 'decimals': 9, 'coingecko_id': 'jito-governance-token'}, + {'symbol': 'W', 'mint': '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', 'decimals': 6, 'coingecko_id': 'wormhole'}, + {'symbol': 'BONK', 'mint': 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', 'decimals': 5, 'coingecko_id': 'bonk'}, + {'symbol': 'ORCA', 'mint': 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', 'decimals': 6, 'coingecko_id': 'orca'}, + {'symbol': 'PENGU', 'mint': '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', 'decimals': 6, 'coingecko_id': 'pudgy-penguins'}, + {'symbol': 'RAY', 'mint': '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', 'decimals': 6, 'coingecko_id': 'raydium'}, +] + + +def get_evm_tokens(chain: ChainCode) -> list[dict]: + if chain == 'ETH': + return ETH_TOKENS + if chain == 'BSC': + return BSC_TOKENS + return [] + + +def get_trx_tokens() -> list[dict]: + return TRX_TOKENS + + +def get_sol_tokens() -> list[dict]: + return SOL_TOKENS + + +def get_coingecko_id(chain: ChainCode, symbol: str) -> str | None: + upper = symbol.upper() + if upper == chain or upper == NATIVE_SYMBOLS.get(chain): + return NATIVE_COINGECKO_IDS.get(chain) + if chain in {'ETH', 'BSC'}: + token = next((x for x in get_evm_tokens(chain) if x['symbol'].upper() == upper), None) + return token['coingecko_id'] if token else None + if chain == 'TRX': + token = next((x for x in TRX_TOKENS if x['symbol'].upper() == upper), None) + return token['coingecko_id'] if token else None + if chain == 'SOL': + token = next((x for x in SOL_TOKENS if x['symbol'].upper() == upper), None) + return token['coingecko_id'] if token else None + return None diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py index 29a9396..29277b1 100644 --- a/src/presentation/dependencies/commands.py +++ b/src/presentation/dependencies/commands.py @@ -5,11 +5,13 @@ from src.application.commands import ( GetOrganizationCommand, GetOrganizationMnemonicCommand, GetOrganizationSecretKeysCommand, + GetOrganizationWalletBalancesCommand, GetPurchaseRequestCommand, ListOrganizationWalletsCommand, ListPurchaseRequestsCommand, ) from src.application.contracts import ILogger +from src.infrastructure.wallet_balances import WalletBalanceService from src.presentation.dependencies.logger import get_logger from src.presentation.dependencies.unit_of_work import get_unit_of_work @@ -49,6 +51,17 @@ def get_list_organization_wallets_command( return ListOrganizationWalletsCommand(unit_of_work=unit_of_work, logger=logger) +def get_get_organization_wallet_balances_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), +) -> GetOrganizationWalletBalancesCommand: + return GetOrganizationWalletBalancesCommand( + unit_of_work=unit_of_work, + balance_service=WalletBalanceService(), + logger=logger, + ) + + def get_get_organization_mnemonic_command( logger: ILogger = Depends(get_logger), unit_of_work: IUnitOfWork = Depends(get_unit_of_work), diff --git a/src/presentation/routing/organizations.py b/src/presentation/routing/organizations.py index fa7cf02..8f4a2c5 100644 --- a/src/presentation/routing/organizations.py +++ b/src/presentation/routing/organizations.py @@ -4,6 +4,7 @@ from src.application.commands import ( GetOrganizationCommand, GetOrganizationMnemonicCommand, GetOrganizationSecretKeysCommand, + GetOrganizationWalletBalancesCommand, ListOrganizationWalletsCommand, ) from src.application.domain.dto import AuthContext @@ -12,18 +13,21 @@ from src.presentation.dependencies.commands import ( get_get_organization_command, get_get_organization_mnemonic_command, get_get_organization_secret_keys_command, + get_get_organization_wallet_balances_command, get_list_organization_wallets_command, ) from src.presentation.schemas.organization import ( MnemonicResponse, OrganizationResponse, SecretKeyResponse, + WalletBalancesResponse, WalletResponse, ) from src.presentation.serializers.organization import ( mnemonic_to_response, organization_to_response, secret_key_to_response, + wallet_balances_to_response, wallet_to_response, ) @@ -48,6 +52,15 @@ async def list_organization_wallets( return [wallet_to_response(w) for w in wallets] +@organizations_router.get('/wallets/balances', response_model=WalletBalancesResponse) +async def get_organization_wallet_balances( + auth: AuthContext = Depends(require_access_token), + command: GetOrganizationWalletBalancesCommand = Depends(get_get_organization_wallet_balances_command), +): + balances = await command(auth.user_id) + return wallet_balances_to_response(balances) + + @organizations_router.get('/wallets/mnemonic', response_model=MnemonicResponse) async def get_organization_mnemonic( auth: AuthContext = Depends(require_access_token), diff --git a/src/presentation/schemas/organization.py b/src/presentation/schemas/organization.py index f70b3bc..845a412 100644 --- a/src/presentation/schemas/organization.py +++ b/src/presentation/schemas/organization.py @@ -35,6 +35,32 @@ class WalletResponse(BaseModel): created_at: str | None +class FormattedAmountResponse(BaseModel): + raw: str + formatted: str + decimals: int + usd_price: str | None + usd_value: str | None + + +class WalletBalanceResponse(BaseModel): + id: str + chain: str + address: str + derivation_path: str + native_symbol: str + native: FormattedAmountResponse + tokens: dict[str, FormattedAmountResponse] + total_usd: str | None + error: str | None + + +class WalletBalancesResponse(BaseModel): + items: list[WalletBalanceResponse] + total_usd: str | None + has_errors: bool + + class MnemonicResponse(BaseModel): mnemonic: str diff --git a/src/presentation/serializers/organization.py b/src/presentation/serializers/organization.py index fcb4620..59c8adf 100644 --- a/src/presentation/serializers/organization.py +++ b/src/presentation/serializers/organization.py @@ -3,11 +3,15 @@ from __future__ import annotations from src.application.domain.entities.legal_entity import LegalEntityEntity from src.application.domain.entities.organization_wallet import OrganizationWalletEntity from src.presentation.schemas.organization import ( + FormattedAmountResponse, MnemonicResponse, OrganizationResponse, SecretKeyResponse, + WalletBalanceResponse, + WalletBalancesResponse, WalletResponse, ) +from src.infrastructure.wallet_balances.service import FormattedAmount, WalletBalance def organization_to_response(entity: LegalEntityEntity) -> OrganizationResponse: @@ -44,6 +48,40 @@ def wallet_to_response(entity: OrganizationWalletEntity) -> WalletResponse: ) +def formatted_amount_to_response(amount: FormattedAmount) -> FormattedAmountResponse: + return FormattedAmountResponse( + raw=amount.raw, + formatted=amount.formatted, + decimals=amount.decimals, + usd_price=str(amount.usd_price) if amount.usd_price is not None else None, + usd_value=str(amount.usd_value) if amount.usd_value is not None else None, + ) + + +def wallet_balance_to_response(entity: WalletBalance) -> WalletBalanceResponse: + return WalletBalanceResponse( + id=entity.wallet_id, + chain=entity.chain, + address=entity.address, + derivation_path=entity.derivation_path, + native_symbol=entity.native_symbol, + native=formatted_amount_to_response(entity.native), + tokens={k: formatted_amount_to_response(v) for k, v in entity.tokens.items()}, + total_usd=str(entity.total_usd) if entity.total_usd is not None else None, + error=entity.error, + ) + + +def wallet_balances_to_response(items: list[WalletBalance]) -> WalletBalancesResponse: + values = [item.total_usd for item in items if item.total_usd is not None] + total = sum(values) if values else None + return WalletBalancesResponse( + items=[wallet_balance_to_response(item) for item in items], + total_usd=str(total) if total is not None else None, + has_errors=any(item.error is not None for item in items), + ) + + def mnemonic_to_response(mnemonic: str) -> MnemonicResponse: return MnemonicResponse(mnemonic=mnemonic)