feat: add wallets balance

This commit is contained in:
2026-06-10 18:21:23 +03:00
parent 6c3628edfa
commit 15c06fa9cc
11 changed files with 622 additions and 0 deletions

View File

@@ -12,6 +12,7 @@ dependencies = [
"fastapi==0.128.7", "fastapi==0.128.7",
"granian==2.6.1", "granian==2.6.1",
"hvac==2.4.0", "hvac==2.4.0",
"httpx==0.28.1",
"itsdangerous==2.2.0", "itsdangerous==2.2.0",
"pydantic-settings==2.12.0", "pydantic-settings==2.12.0",
"python-jose==3.5.0", "python-jose==3.5.0",

View File

@@ -2,6 +2,7 @@ from src.application.commands.organization_commands import GetOrganizationComman
from src.application.commands.organization_wallet_commands import ( from src.application.commands.organization_wallet_commands import (
GetOrganizationMnemonicCommand, GetOrganizationMnemonicCommand,
GetOrganizationSecretKeysCommand, GetOrganizationSecretKeysCommand,
GetOrganizationWalletBalancesCommand,
ListOrganizationWalletsCommand, ListOrganizationWalletsCommand,
) )
from src.application.commands.purchase_request_commands import ( from src.application.commands.purchase_request_commands import (

View File

@@ -13,6 +13,8 @@ from src.infrastructure.crypto.wallet_crypto import (
is_crypto_ready, is_crypto_ready,
) )
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
from src.infrastructure.wallet_balances import WalletBalanceService
from src.infrastructure.wallet_balances.service import WalletBalance
class ListOrganizationWalletsCommand: class ListOrganizationWalletsCommand:
@@ -26,6 +28,19 @@ class ListOrganizationWalletsCommand:
return await self._unit_of_work.organization_wallet_repository.list_by_organization(org.id) 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: class GetOrganizationMnemonicCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work self._unit_of_work = unit_of_work

View File

@@ -74,6 +74,14 @@ class Settings(BaseSettings):
JWT_ALGORITHM: str = 'RS256' JWT_ALGORITHM: str = 'RS256'
JWT_KEYS_REFRESH_SECONDS: int = 3600 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_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO'
LOG_FORMAT: Literal['JSON', 'TEXT'] = 'JSON' LOG_FORMAT: Literal['JSON', 'TEXT'] = 'JSON'
@@ -121,6 +129,31 @@ class Settings(BaseSettings):
if kv(db, 'PASSWORD', 'password') is not None: if kv(db, 'PASSWORD', 'password') is not None:
object.__setattr__(self, 'DATABASE_PASSWORD', str(kv(db, 'PASSWORD', 'password'))) 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) csrf = client.read_secret_optional(self.VAULT_CSRF_SECRET_PATH)
if csrf and kv(csrf, 'KEY', 'key') is not None: if csrf and kv(csrf, 'KEY', 'key') is not None:
key = str(kv(csrf, 'KEY', 'key')) key = str(kv(csrf, 'KEY', 'key'))

View File

@@ -0,0 +1,4 @@
from src.infrastructure.wallet_balances.service import WalletBalanceService
__all__ = ['WalletBalanceService']

View File

@@ -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()

View File

@@ -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

View File

@@ -5,11 +5,13 @@ from src.application.commands import (
GetOrganizationCommand, GetOrganizationCommand,
GetOrganizationMnemonicCommand, GetOrganizationMnemonicCommand,
GetOrganizationSecretKeysCommand, GetOrganizationSecretKeysCommand,
GetOrganizationWalletBalancesCommand,
GetPurchaseRequestCommand, GetPurchaseRequestCommand,
ListOrganizationWalletsCommand, ListOrganizationWalletsCommand,
ListPurchaseRequestsCommand, ListPurchaseRequestsCommand,
) )
from src.application.contracts import ILogger 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.logger import get_logger
from src.presentation.dependencies.unit_of_work import get_unit_of_work 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) 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( def get_get_organization_mnemonic_command(
logger: ILogger = Depends(get_logger), logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work), unit_of_work: IUnitOfWork = Depends(get_unit_of_work),

View File

@@ -4,6 +4,7 @@ from src.application.commands import (
GetOrganizationCommand, GetOrganizationCommand,
GetOrganizationMnemonicCommand, GetOrganizationMnemonicCommand,
GetOrganizationSecretKeysCommand, GetOrganizationSecretKeysCommand,
GetOrganizationWalletBalancesCommand,
ListOrganizationWalletsCommand, ListOrganizationWalletsCommand,
) )
from src.application.domain.dto import AuthContext from src.application.domain.dto import AuthContext
@@ -12,18 +13,21 @@ from src.presentation.dependencies.commands import (
get_get_organization_command, get_get_organization_command,
get_get_organization_mnemonic_command, get_get_organization_mnemonic_command,
get_get_organization_secret_keys_command, get_get_organization_secret_keys_command,
get_get_organization_wallet_balances_command,
get_list_organization_wallets_command, get_list_organization_wallets_command,
) )
from src.presentation.schemas.organization import ( from src.presentation.schemas.organization import (
MnemonicResponse, MnemonicResponse,
OrganizationResponse, OrganizationResponse,
SecretKeyResponse, SecretKeyResponse,
WalletBalancesResponse,
WalletResponse, WalletResponse,
) )
from src.presentation.serializers.organization import ( from src.presentation.serializers.organization import (
mnemonic_to_response, mnemonic_to_response,
organization_to_response, organization_to_response,
secret_key_to_response, secret_key_to_response,
wallet_balances_to_response,
wallet_to_response, wallet_to_response,
) )
@@ -48,6 +52,15 @@ async def list_organization_wallets(
return [wallet_to_response(w) for w in 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) @organizations_router.get('/wallets/mnemonic', response_model=MnemonicResponse)
async def get_organization_mnemonic( async def get_organization_mnemonic(
auth: AuthContext = Depends(require_access_token), auth: AuthContext = Depends(require_access_token),

View File

@@ -35,6 +35,32 @@ class WalletResponse(BaseModel):
created_at: str | None 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): class MnemonicResponse(BaseModel):
mnemonic: str mnemonic: str

View File

@@ -3,11 +3,15 @@ from __future__ import annotations
from src.application.domain.entities.legal_entity import LegalEntityEntity from src.application.domain.entities.legal_entity import LegalEntityEntity
from src.application.domain.entities.organization_wallet import OrganizationWalletEntity from src.application.domain.entities.organization_wallet import OrganizationWalletEntity
from src.presentation.schemas.organization import ( from src.presentation.schemas.organization import (
FormattedAmountResponse,
MnemonicResponse, MnemonicResponse,
OrganizationResponse, OrganizationResponse,
SecretKeyResponse, SecretKeyResponse,
WalletBalanceResponse,
WalletBalancesResponse,
WalletResponse, WalletResponse,
) )
from src.infrastructure.wallet_balances.service import FormattedAmount, WalletBalance
def organization_to_response(entity: LegalEntityEntity) -> OrganizationResponse: 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: def mnemonic_to_response(mnemonic: str) -> MnemonicResponse:
return MnemonicResponse(mnemonic=mnemonic) return MnemonicResponse(mnemonic=mnemonic)