Compare commits
4 Commits
notify_sys
...
162a3856a6
| Author | SHA1 | Date | |
|---|---|---|---|
| 162a3856a6 | |||
| 15c06fa9cc | |||
| 6c3628edfa | |||
| e1f074b94d |
@@ -12,10 +12,12 @@ 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",
|
||||
"python-ulid==3.1.0",
|
||||
"sqlalchemy==2.0.46",
|
||||
"orjson==3.11.7",
|
||||
"uvloop==0.22.1; platform_system != 'Windows'",
|
||||
]
|
||||
|
||||
@@ -7,15 +7,3 @@ class ILegalEntityRepository(ABC):
|
||||
@abstractmethod
|
||||
async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_id(self, organization_id: str) -> LegalEntityEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def count_all(self) -> int:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from src.application.commands.organization_commands import (
|
||||
GetOrganizationCommand,
|
||||
ListOrganizationsCommand,
|
||||
)
|
||||
from src.application.commands.organization_commands import GetOrganizationCommand
|
||||
from src.application.commands.organization_wallet_commands import (
|
||||
GetOrganizationMnemonicCommand,
|
||||
GetOrganizationSecretKeysCommand,
|
||||
GetOrganizationWalletBalancesCommand,
|
||||
ListOrganizationWalletsCommand,
|
||||
)
|
||||
from src.application.commands.purchase_request_commands import (
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.commands.legal_entity_guard import require_legal_entity
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.entities.legal_entity import LegalEntityEntity
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class ListOrganizationsCommand:
|
||||
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
|
||||
@transactional
|
||||
async def __call__(self, *, limit: int = 50, offset: int = 0) -> tuple[list[LegalEntityEntity], int]:
|
||||
items = await self._unit_of_work.legal_entity_repository.list_all(limit=limit, offset=offset)
|
||||
total = await self._unit_of_work.legal_entity_repository.count_all()
|
||||
return items, total
|
||||
|
||||
|
||||
class GetOrganizationCommand:
|
||||
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
|
||||
@transactional
|
||||
async def __call__(self, organization_id: str) -> LegalEntityEntity:
|
||||
return await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
|
||||
async def __call__(self, user_id: str) -> LegalEntityEntity:
|
||||
return await require_legal_entity(user_id, self._unit_of_work)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.commands.legal_entity_guard import require_legal_entity
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.entities.legal_entity import LegalEntityEntity
|
||||
from src.application.domain.entities.organization_wallet import OrganizationWalletEntity
|
||||
@@ -12,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:
|
||||
@@ -20,9 +23,22 @@ class ListOrganizationWalletsCommand:
|
||||
self._logger = logger
|
||||
|
||||
@transactional
|
||||
async def __call__(self, organization_id: str) -> list[OrganizationWalletEntity]:
|
||||
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
|
||||
return await self._unit_of_work.organization_wallet_repository.list_by_organization(organization_id)
|
||||
async def __call__(self, user_id: str) -> list[OrganizationWalletEntity]:
|
||||
org = await require_legal_entity(user_id, self._unit_of_work)
|
||||
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:
|
||||
@@ -31,14 +47,14 @@ class GetOrganizationMnemonicCommand:
|
||||
self._logger = logger
|
||||
|
||||
@transactional
|
||||
async def __call__(self, organization_id: str) -> str:
|
||||
org = await self._require_wallets_org(organization_id)
|
||||
async def __call__(self, user_id: str) -> str:
|
||||
org = await self._require_wallets_org(user_id)
|
||||
if not is_crypto_ready():
|
||||
raise ServiceUnavailableException(message='Crypto service not ready')
|
||||
return decrypt_mnemonic(org.encrypted_mnemonic or '')
|
||||
|
||||
async def _require_wallets_org(self, organization_id: str) -> LegalEntityEntity:
|
||||
org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
|
||||
async def _require_wallets_org(self, user_id: str) -> LegalEntityEntity:
|
||||
org = await require_legal_entity(user_id, self._unit_of_work)
|
||||
if not org.encrypted_mnemonic:
|
||||
raise NotFoundException(message='Wallets not created for organization')
|
||||
return org
|
||||
@@ -50,15 +66,15 @@ class GetOrganizationSecretKeysCommand:
|
||||
self._logger = logger
|
||||
|
||||
@transactional
|
||||
async def __call__(self, organization_id: str) -> list[DerivedSecretKey]:
|
||||
org = await self._require_wallets_org(organization_id)
|
||||
async def __call__(self, user_id: str) -> list[DerivedSecretKey]:
|
||||
org = await self._require_wallets_org(user_id)
|
||||
if not is_crypto_ready():
|
||||
raise ServiceUnavailableException(message='Crypto service not ready')
|
||||
mnemonic = decrypt_mnemonic(org.encrypted_mnemonic or '')
|
||||
return derive_all_private_keys(mnemonic)
|
||||
|
||||
async def _require_wallets_org(self, organization_id: str) -> LegalEntityEntity:
|
||||
org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
|
||||
async def _require_wallets_org(self, user_id: str) -> LegalEntityEntity:
|
||||
org = await require_legal_entity(user_id, self._unit_of_work)
|
||||
if not org.encrypted_mnemonic:
|
||||
raise NotFoundException(message='Wallets not created for organization')
|
||||
return org
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
from src.application.domain.dto.token import AccessTokenPayload, AdminAuthContext, AuthContext
|
||||
from src.application.domain.dto.token import AccessTokenPayload, AuthContext
|
||||
from src.application.domain.dto.keys import JwtPublicKey, JwtPublicKeySet
|
||||
@@ -4,8 +4,7 @@ from pydantic import BaseModel
|
||||
class AccessTokenPayload(BaseModel):
|
||||
sub: str
|
||||
type: str
|
||||
sid: str | None = None
|
||||
role: str | None = None
|
||||
sid: str
|
||||
iat: int
|
||||
nbf: int
|
||||
exp: int
|
||||
@@ -16,9 +15,4 @@ class AccessTokenPayload(BaseModel):
|
||||
class AuthContext(BaseModel):
|
||||
user_id: str
|
||||
sid: str
|
||||
token: AccessTokenPayload
|
||||
|
||||
|
||||
class AdminAuthContext(BaseModel):
|
||||
admin_user_id: str
|
||||
role: str
|
||||
token: AccessTokenPayload
|
||||
@@ -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'))
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.entities.legal_entity import LegalEntityEntity
|
||||
from src.application.domain.exceptions import InternalException, NotFoundException
|
||||
from src.application.domain.exceptions import InternalException
|
||||
from src.infrastructure.database.models.legal_entity import LegalEntityModel
|
||||
|
||||
|
||||
@@ -52,39 +52,3 @@ class LegalEntityRepository(ILegalEntityRepository):
|
||||
self._logger.exception(str(exc))
|
||||
raise InternalException(message=f'Database error: {exc}') from exc
|
||||
|
||||
async def get_by_id(self, organization_id: str) -> LegalEntityEntity:
|
||||
try:
|
||||
stmt = select(LegalEntityModel).where(LegalEntityModel.id == organization_id)
|
||||
result = await self._session.execute(stmt)
|
||||
model = result.scalar_one_or_none()
|
||||
if model is None:
|
||||
raise NotFoundException(message='Organization not found')
|
||||
return self._to_entity(model)
|
||||
except NotFoundException:
|
||||
raise
|
||||
except SQLAlchemyError as exc:
|
||||
self._logger.exception(str(exc))
|
||||
raise InternalException(message=f'Database error: {exc}') from exc
|
||||
|
||||
async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]:
|
||||
try:
|
||||
stmt = (
|
||||
select(LegalEntityModel)
|
||||
.order_by(LegalEntityModel.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return [self._to_entity(m) for m in result.scalars().all()]
|
||||
except SQLAlchemyError as exc:
|
||||
self._logger.exception(str(exc))
|
||||
raise InternalException(message=f'Database error: {exc}') from exc
|
||||
|
||||
async def count_all(self) -> int:
|
||||
try:
|
||||
stmt = select(func.count()).select_from(LegalEntityModel)
|
||||
result = await self._session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
except SQLAlchemyError as exc:
|
||||
self._logger.exception(str(exc))
|
||||
raise InternalException(message=f'Database error: {exc}') from exc
|
||||
|
||||
@@ -23,8 +23,7 @@ class JwtService(IJwtService):
|
||||
return AccessTokenPayload(
|
||||
sub=str(payload['sub']),
|
||||
type='access',
|
||||
sid=str(payload['sid']) if payload.get('sid') else None,
|
||||
role=str(payload['role']) if payload.get('role') else None,
|
||||
sid=str(payload['sid']),
|
||||
iat=int(payload['iat']),
|
||||
nbf=int(payload['nbf']),
|
||||
exp=int(payload['exp']),
|
||||
@@ -84,9 +83,9 @@ class JwtService(IJwtService):
|
||||
options=options,
|
||||
)
|
||||
|
||||
if 'sid' not in payload and 'role' not in payload:
|
||||
self._logger.warning(f'JWT missing sid or role claim kid={kid}')
|
||||
raise ApplicationException(status_code=401, message='Missing token claim: sid or role')
|
||||
if 'sid' not in payload:
|
||||
self._logger.warning(f'JWT missing sid claim kid={kid}')
|
||||
raise ApplicationException(status_code=401, message='Missing token claim: sid')
|
||||
|
||||
if 'type' not in payload:
|
||||
self._logger.warning(f'JWT missing type claim kid={kid}')
|
||||
|
||||
4
src/infrastructure/wallet_balances/__init__.py
Normal file
4
src/infrastructure/wallet_balances/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from src.infrastructure.wallet_balances.service import WalletBalanceService
|
||||
|
||||
|
||||
__all__ = ['WalletBalanceService']
|
||||
373
src/infrastructure/wallet_balances/service.py
Normal file
373
src/infrastructure/wallet_balances/service.py
Normal 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()
|
||||
105
src/infrastructure/wallet_balances/token_registry.py
Normal file
105
src/infrastructure/wallet_balances/token_registry.py
Normal 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
|
||||
@@ -1,47 +0,0 @@
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
|
||||
from src.application.contracts import IJwtService
|
||||
from src.application.domain.dto import AdminAuthContext
|
||||
from src.application.domain.exceptions import ForbiddenException, UnauthorizedException
|
||||
from src.presentation.dependencies.security import get_jwt_service
|
||||
|
||||
|
||||
def _extract_bearer_token(request: Request) -> str | None:
|
||||
auth = request.headers.get('Authorization')
|
||||
if not auth:
|
||||
return None
|
||||
scheme, param = get_authorization_scheme_param(auth)
|
||||
if scheme.lower() == 'bearer' and param:
|
||||
return param
|
||||
return None
|
||||
|
||||
|
||||
async def require_admin_access(
|
||||
request: Request,
|
||||
jwt_service: IJwtService = Depends(get_jwt_service),
|
||||
) -> AdminAuthContext:
|
||||
token = _extract_bearer_token(request)
|
||||
if not token:
|
||||
raise UnauthorizedException(message='Authorization Bearer token required')
|
||||
|
||||
payload = await jwt_service.decode_access_token(token)
|
||||
if payload.type != 'access':
|
||||
raise UnauthorizedException(message='Invalid token type')
|
||||
|
||||
role = payload.role
|
||||
if not role:
|
||||
raise UnauthorizedException(message='Token missing role')
|
||||
|
||||
return AdminAuthContext(admin_user_id=payload.sub, role=role)
|
||||
|
||||
|
||||
def require_admin_role(*allowed_roles: str):
|
||||
allowed = set(allowed_roles)
|
||||
|
||||
async def dependency(auth: AdminAuthContext = Depends(require_admin_access)) -> AdminAuthContext:
|
||||
if auth.role not in allowed:
|
||||
raise ForbiddenException(message='Insufficient permissions')
|
||||
return auth
|
||||
|
||||
return dependency
|
||||
@@ -5,12 +5,13 @@ from src.application.commands import (
|
||||
GetOrganizationCommand,
|
||||
GetOrganizationMnemonicCommand,
|
||||
GetOrganizationSecretKeysCommand,
|
||||
GetOrganizationWalletBalancesCommand,
|
||||
GetPurchaseRequestCommand,
|
||||
ListOrganizationWalletsCommand,
|
||||
ListOrganizationsCommand,
|
||||
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
|
||||
|
||||
@@ -36,13 +37,6 @@ def get_get_purchase_request_command(
|
||||
return GetPurchaseRequestCommand(unit_of_work=unit_of_work, logger=logger)
|
||||
|
||||
|
||||
def get_list_organizations_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
) -> ListOrganizationsCommand:
|
||||
return ListOrganizationsCommand(unit_of_work=unit_of_work, logger=logger)
|
||||
|
||||
|
||||
def get_get_organization_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
@@ -57,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),
|
||||
|
||||
@@ -1,89 +1,81 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from src.application.commands import (
|
||||
GetOrganizationCommand,
|
||||
GetOrganizationMnemonicCommand,
|
||||
GetOrganizationSecretKeysCommand,
|
||||
GetOrganizationWalletBalancesCommand,
|
||||
ListOrganizationWalletsCommand,
|
||||
ListOrganizationsCommand,
|
||||
)
|
||||
from src.application.domain.dto import AdminAuthContext
|
||||
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
|
||||
from src.application.domain.dto import AuthContext
|
||||
from src.presentation.decorators.auth import require_access_token
|
||||
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,
|
||||
get_list_organizations_command,
|
||||
)
|
||||
from src.presentation.schemas.organization import (
|
||||
MnemonicResponse,
|
||||
OrganizationListResponse,
|
||||
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,
|
||||
)
|
||||
|
||||
organizations_router = APIRouter(prefix='/organizations', tags=['organizations'])
|
||||
|
||||
|
||||
@organizations_router.get('', response_model=OrganizationListResponse)
|
||||
async def list_organizations(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
auth: AdminAuthContext = Depends(require_admin_access),
|
||||
command: ListOrganizationsCommand = Depends(get_list_organizations_command),
|
||||
):
|
||||
items, total = await command(limit=limit, offset=offset)
|
||||
return OrganizationListResponse(
|
||||
items=[organization_to_response(x) for x in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@organizations_router.get('/{organization_id}', response_model=OrganizationResponse)
|
||||
@organizations_router.get('', response_model=OrganizationResponse)
|
||||
async def get_organization(
|
||||
organization_id: str,
|
||||
auth: AdminAuthContext = Depends(require_admin_access),
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: GetOrganizationCommand = Depends(get_get_organization_command),
|
||||
):
|
||||
org = await command(organization_id)
|
||||
org = await command(auth.user_id)
|
||||
return organization_to_response(org)
|
||||
|
||||
|
||||
@organizations_router.get('/{organization_id}/wallets', response_model=list[WalletResponse])
|
||||
@organizations_router.get('/wallets', response_model=list[WalletResponse])
|
||||
async def list_organization_wallets(
|
||||
organization_id: str,
|
||||
auth: AdminAuthContext = Depends(require_admin_access),
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: ListOrganizationWalletsCommand = Depends(get_list_organization_wallets_command),
|
||||
):
|
||||
wallets = await command(organization_id)
|
||||
wallets = await command(auth.user_id)
|
||||
return [wallet_to_response(w) for w in wallets]
|
||||
|
||||
|
||||
@organizations_router.get('/{organization_id}/wallets/mnemonic', response_model=MnemonicResponse)
|
||||
@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(
|
||||
organization_id: str,
|
||||
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: GetOrganizationMnemonicCommand = Depends(get_get_organization_mnemonic_command),
|
||||
):
|
||||
mnemonic = await command(organization_id)
|
||||
mnemonic = await command(auth.user_id)
|
||||
return mnemonic_to_response(mnemonic)
|
||||
|
||||
|
||||
@organizations_router.get('/{organization_id}/wallets/secret-keys', response_model=list[SecretKeyResponse])
|
||||
@organizations_router.get('/wallets/secret-keys', response_model=list[SecretKeyResponse])
|
||||
async def get_organization_secret_keys(
|
||||
organization_id: str,
|
||||
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: GetOrganizationSecretKeysCommand = Depends(get_get_organization_secret_keys_command),
|
||||
):
|
||||
keys = await command(organization_id)
|
||||
keys = await command(auth.user_id)
|
||||
return [
|
||||
secret_key_to_response(
|
||||
chain=k.chain,
|
||||
|
||||
@@ -27,11 +27,6 @@ class OrganizationResponse(BaseModel):
|
||||
updated_at: str | None
|
||||
|
||||
|
||||
class OrganizationListResponse(BaseModel):
|
||||
items: list[OrganizationResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class WalletResponse(BaseModel):
|
||||
id: str
|
||||
chain: str
|
||||
@@ -40,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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
64
uv.lock
generated
64
uv.lock
generated
@@ -73,8 +73,10 @@ dependencies = [
|
||||
{ name = "dotenv" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "granian" },
|
||||
{ name = "httpx" },
|
||||
{ name = "hvac" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "orjson" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-jose" },
|
||||
{ name = "python-ulid" },
|
||||
@@ -91,8 +93,10 @@ requires-dist = [
|
||||
{ name = "dotenv", specifier = "==0.9.9" },
|
||||
{ name = "fastapi", specifier = "==0.128.7" },
|
||||
{ name = "granian", specifier = "==2.6.1" },
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
{ name = "hvac", specifier = "==2.4.0" },
|
||||
{ name = "itsdangerous", specifier = "==2.2.0" },
|
||||
{ name = "orjson", specifier = "==3.11.7" },
|
||||
{ name = "pydantic-settings", specifier = "==2.12.0" },
|
||||
{ name = "python-jose", specifier = "==3.5.0" },
|
||||
{ name = "python-ulid", specifier = "==3.1.0" },
|
||||
@@ -351,6 +355,43 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hvac"
|
||||
version = "2.4.0"
|
||||
@@ -381,6 +422,29 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.11.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-sr25519-bindings"
|
||||
version = "0.2.3"
|
||||
|
||||
Reference in New Issue
Block a user