diff --git a/src/components/providers/binanceProvider.ts b/src/components/providers/binanceProvider.ts new file mode 100644 index 00000000..18a53879 --- /dev/null +++ b/src/components/providers/binanceProvider.ts @@ -0,0 +1,24 @@ +import axios from 'axios'; + +import { USE_MOCKS } + from '../config/env'; + +import { getMockPrice } + from '../mocks/mockBinance'; + +export async function getPrice( + symbol: string, +) { + if (USE_MOCKS) { + return getMockPrice( + symbol, + ); + } + + const response = + await axios.get( + `https://api.binance.com/api/v3/ticker/price?symbol=${symbol}`, + ); + + return response.data; +} \ No newline at end of file diff --git a/src/components/providers/vtpassProvider.ts b/src/components/providers/vtpassProvider.ts new file mode 100644 index 00000000..ddc7da53 --- /dev/null +++ b/src/components/providers/vtpassProvider.ts @@ -0,0 +1,35 @@ +import axios from 'axios'; + +import { USE_MOCKS } + from '../config/env'; + +import { + mockPurchaseAirtime, +} from '../mocks/mockVtpass'; + +export async function purchaseAirtime( + phone: string, + amount: number, +) { + if (USE_MOCKS) { + return mockPurchaseAirtime( + phone, + amount, + ); + } + + return axios.post( + process.env.VTPASS_URL!, + { + phone, + amount, + }, + { + headers: { + Authorization: + process.env + .VTPASS_API_KEY!, + }, + }, + ); +} \ No newline at end of file diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 00000000..6ba685f3 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,3 @@ +export const USE_MOCKS = + process.env.USE_MOCKS === + 'true'; \ No newline at end of file diff --git a/src/config/logger.ts b/src/config/logger.ts index 0c2a1853..dd5ffe52 100644 --- a/src/config/logger.ts +++ b/src/config/logger.ts @@ -1,5 +1,28 @@ import winston from 'winston'; + +const { + combine, + timestamp, + errors, + json, + colorize, + printf, +} = winston.format; + +const consoleFormat = printf( + ({ + level, + message, + timestamp, + stack, + }) => { + return `${timestamp} [${level}]: ${ + stack || message + }`; + }, +); + import { HttpLogTransport } from '../transport/httpLogTransport'; @@ -23,12 +46,43 @@ export const logger = winston.createLogger({ level: 'info', - format: - winston.format.combine( - winston.format.timestamp(), + format: combine( + timestamp(), + errors({ stack: true }), + json(), + ), - winston.format.json(), - ), + defaultMeta: { + service: 'backend-api', + }, + + transports: [ + // Error Logs + new winston.transports.File({ + filename: + 'logs/error.log', + level: 'error', + }), - transports, - }); \ No newline at end of file + // Combined Logs + new winston.transports.File({ + filename: + 'logs/combined.log', + }), + ], + }); + +if ( + process.env.NODE_ENV !== + 'production' +) { + logger.add( + new winston.transports.Console({ + format: combine( + colorize(), + timestamp(), + consoleFormat, + ), + }), + ); +} \ No newline at end of file diff --git a/src/middleware/requestLogger.ts b/src/middleware/requestLogger.ts new file mode 100644 index 00000000..63d85c27 --- /dev/null +++ b/src/middleware/requestLogger.ts @@ -0,0 +1,18 @@ +import morgan from 'morgan'; + +import { logger } + from '../config/logger'; + +const stream = { + write: (message: string) => { + logger.info( + message.trim(), + ); + }, +}; + +export const requestLogger = + morgan( + ':method :url :status :response-time ms', + { stream }, + ); \ No newline at end of file diff --git a/src/mocks/mockBinance.ts b/src/mocks/mockBinance.ts new file mode 100644 index 00000000..7394049d --- /dev/null +++ b/src/mocks/mockBinance.ts @@ -0,0 +1,20 @@ +import { + randomFloat, +} from '../utils/random'; + +export async function getMockPrice( + symbol: string, +) { + return { + symbol, + + price: randomFloat( + 25000, + 70000, + 2, + ), + + timestamp: + Date.now(), + }; +} \ No newline at end of file diff --git a/src/mocks/mockVtpass.ts b/src/mocks/mockVtpass.ts new file mode 100644 index 00000000..9fe26915 --- /dev/null +++ b/src/mocks/mockVtpass.ts @@ -0,0 +1,28 @@ +import { + randomChoice, +} from '../utils/random'; + +export async function mockPurchaseAirtime( + phone: string, + amount: number, +) { + return { + success: true, + + phone, + + amount, + + transactionId: + crypto.randomUUID(), + + provider: randomChoice([ + 'MTN', + 'Airtel', + 'Glo', + '9mobile', + ]), + + status: 'successful', + }; +} \ No newline at end of file diff --git a/src/network/__tests__/test_polling.py b/src/network/__tests__/test_polling.py new file mode 100644 index 00000000..90f1488c --- /dev/null +++ b/src/network/__tests__/test_polling.py @@ -0,0 +1,63 @@ +import pytest +import asyncio +import time +from src.network.polling import RegionalPollingEngine + +class MockResponse: + def __init__(self, status, json_data): + self.status = status + self._json_data = json_data + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass + + async def json(self): + return self._json_data + +@pytest.mark.asyncio +async def test_polling_engine_enforces_2500ms_timeout_cutoff(monkeypatch): + """ + Asserts that if an endpoint hangs indefinitely, the engine drops it at 2500ms + and returns a clean failure object without stalling other tasks. + """ + # 1. Setup a normal fast region alongside an un-responsive lagging region + endpoints = { + "FAST-NODE": "https://fast.node/api", + "LAGGING-NODE": "https://laggy.node/api" + } + + engine = RegionalPollingEngine(endpoints) + + # 2. Mock aiohttp.ClientSession.get to simulate varying endpoint performance + async def mock_get(self, url, **kwargs): + if "fast" in url: + return MockResponse(200, {"metrics": "nominal"}) + elif "laggy" in url: + # Simulate a 10-second backend hang, well beyond our 2.5s cutoff + await asyncio.sleep(10.0) + return MockResponse(200, {"metrics": "delayed"}) + return MockResponse(404, {}) + + monkeypatch.setattr("aiohttp.ClientSession.get", mock_get) + + start_time = time.monotonic() + results = await engine.poll_all_regions_concurrently() + duration = time.monotonic() - start_time + + # 3. Assertions and Structural Checks + # The overall run should finish shortly after the 2.5s limit, not waiting 10s + assert duration < 3.0, f"The execution pool stalled! Total duration took too long: {duration}s" + + fast_node_res = next(r for r in results if r["region"] == "FAST-NODE") + laggy_node_res = next(r for r in results if r["region"] == "LAGGING-NODE") + + # Fast node should return successfully + assert fast_node_res["status"] == "SUCCESS" + assert fast_node_res["payload"]["metrics"] == "nominal" + + # Lagging node should be caught and dropped cleanly by the timeout guard + assert laggy_node_res["status"] == "TIMEOUT" + assert "2500ms threshold" in laggy_node_res["error"] \ No newline at end of file diff --git a/src/network/__tests__/test_rpc_supervisor.py b/src/network/__tests__/test_rpc_supervisor.py new file mode 100644 index 00000000..08ebfbd8 --- /dev/null +++ b/src/network/__tests__/test_rpc_supervisor.py @@ -0,0 +1,38 @@ +import pytest +import asyncio +from src.network.nonce_tracker import PredictiveRPCSupervisor, HorizonNodeProfile + +class MockResponseContext: + def __init__(self, status): + self.status = status + async def __aenter__(self): return self + async def __aexit__(self, exc_type, exc_val, exc_tb): pass + +@pytest.mark.asyncio +async def test_rpc_supervisor_preemptive_failover_logic(monkeypatch): + """ + Asserts that the supervisor instantly shifts traffic to secondary backup nodes + if the primary node's health drops below acceptable bounds. + """ + primary = [{"name": "primary-node-01", "url": "https://primary.stellar.org"}] + fallback = [{"name": "backup-node-01", "url": "https://backup.stellar.org"}] + + supervisor = PredictiveRPCSupervisor(primary, fallback) + assert supervisor.get_active_endpoint_url() == "https://primary.stellar.org" + + # Mock client session requests to simulate primary node failure and backup success + async def mock_get(self, url, **kwargs): + if "primary" in url: + # Simulate network timeout block + await asyncio.sleep(2.0) + return MockResponseContext(504) + return MockResponseContext(200) + + monkeypatch.setattr("aiohttp.ClientSession.get", mock_get) + + # Trigger predictive health evaluation cycles + await supervisor.run_predictive_ping_cycle() + + # The supervisor should notice the primary node timeout and shift to the backup node + assert supervisor.active_node.is_healthy is False + assert supervisor.get_active_endpoint_url() == "https://backup.stellar.org" \ No newline at end of file diff --git a/src/network/nonce_tracker.py b/src/network/nonce_tracker.py index c04ce583..f4221d84 100644 --- a/src/network/nonce_tracker.py +++ b/src/network/nonce_tracker.py @@ -1,3 +1,124 @@ +import asyncio +import logging +import time +from typing import Dict, List, Optional, Any +import aiohttp + +logger = logging.getLogger("Network.RPCSup") + +# Threshold Parameters +LIGHTWEIGHT_PING_TIMEOUT = 0.8 # Max acceptable time window (800ms) before degradation warning +MOVING_AVG_WINDOW_SIZE = 4 # Number of historic latency checks to weigh mathematically + +class HorizonNodeProfile: + def __init__(self, name: str, url: str): + self.name = name + self.url = url + self.latency_history: List[float] = [] + self.is_healthy = True + + @property + def moving_average_latency(self) -> float: + """Calculates historical moving average execution latency parameters.""" + if not self.latency_history: + return 0.0 + return sum(self.latency_history) / len(self.latency_history) + + def record_metric(self, latency_ms: float): + """Appends latency sample to bounded historic window tracking loops.""" + self.latency_history.append(latency_ms) + if len(self.latency_history) > MOVING_AVG_WINDOW_SIZE: + self.latency_history.pop(0) + + +class PredictiveRPCSupervisor: + def __init__(self, primary_endpoints: List[Dict[str, str]], fallback_endpoints: List[Dict[str, str]]): + """ + Orchestrates network health scoring topologies across core and backup infrastructure arrays. + Input format example: [{"name": "horizon-main", "url": "https://horizon.stellar.org"}] + """ + self.primary_pool = [HorizonNodeProfile(node["name"], node["url"]) for node in primary_endpoints] + self.fallback_pool = [HorizonNodeProfile(node["name"], node["url"]) for node in fallback_endpoints] + self.active_node: HorizonNodeProfile = self.primary_pool[0] + + async def run_predictive_ping_cycle(self) -> None: + """ + Executes parallel, lightweight validation pings across the cluster. + Updates health statuses without introducing blocking execution lags to outer worker frameworks. + """ + async with aiohttp.ClientSession() as session: + tasks = [] + all_nodes = self.primary_pool + self.fallback_pool + + for node in all_nodes: + tasks.append(self._probe_node_health(session, node)) + + await asyncio.gather(*tasks) + + self._evaluate_routing_topology() + + async def _probe_node_health(self, session: aiohttp.ClientSession, node: HorizonNodeProfile) -> None: + """ + Dispatches lightweight low-overhead endpoint probes to track real-time communication shifts. + """ + # Horizon base path used for lightweight connection checks + probe_url = f"{node.url.rstrip('/')}/" + start_time = time.monotonic() + + try: + async with asyncio.timeout(LIGHTWEIGHT_PING_TIMEOUT): + async with session.get(probe_url) as response: + if response.status == 200: + latency_ms = (time.monotonic() - start_time) * 1000 + node.record_metric(latency_ms) + + # Mark degraded if moving average indicates systematic latency decline + if node.moving_average_latency > (LIGHTWEIGHT_PING_TIMEOUT * 1000): + if node.is_healthy: + logger.warning(f"Predictive Warning: Performance degradation detected on {node.name}. Latency: {node.moving_average_latency:.1f}ms") + node.is_healthy = False + else: + node.is_healthy = True + return + + node.is_healthy = False + logger.debug(f"Node {node.name} returned non-200 footprint status: {response.status}") + + except (asyncio.TimeoutError, aiohttp.ClientError): + node.is_healthy = False + node.record_metric(LIGHTWEIGHT_PING_TIMEOUT * 1000 * 2) # Penalize metric tracking log + logger.warn(f"Predictive Supervisor flagged node [{node.name}] as UNHEALTHY (Timeout/Network breakdown)") + + def _evaluate_routing_topology(self) -> None: + """ + Dynamically shifts layout traffic pointers to healthier candidate environments. + """ + # If active node is healthy and performing nominal processing, preserve active route + if self.active_node.is_healthy: + return + + logger.warn(f"Active Horizon Endpoint [{self.active_node.name}] degraded. Initializing preemptive failover routine...") + + # 1. Scan primary pool for an alternate healthy node + for primary in self.primary_pool: + if primary.is_healthy: + self.active_node = primary + logger.info(f"Traffic routing safely shifted to alternate primary node: [{self.active_node.name}]") + return + + # 2. Fallback to secondary isolated backup arrays if full primary tier crashes + for fallback in self.fallback_pool: + if fallback.is_healthy: + self.active_node = fallback + logger.critical(f"EMERGENCY: Primary Horizon node array completely degraded! Failover routed to backup: [{self.active_node.name}]") + return + + logger.error("CRITICAL FAILURE: Comprehensive Horizon node matrix completely unreachable. No healthy nodes found.") + + def get_active_endpoint_url(self) -> str: + """Returns the currently active, validated node URL for ledger submissions.""" + return self.active_node.url + import logging import threading from typing import Dict, Optional diff --git a/src/network/polling.py b/src/network/polling.py index 5c3bbebd..99e0fba1 100644 --- a/src/network/polling.py +++ b/src/network/polling.py @@ -1,3 +1,84 @@ +import asyncio +import logging +import time +from typing import Dict, List, Any, Optional +import aiohttp + +logger = logging.getLogger("Network.Polling") + +# Strict operational threshold limits +REGIONAL_TIMEOUT_SECONDS = 2.5 # 2500ms threshold bounds + +class RegionalPollingEngine: + def __init__(self, endpoints: Dict[str, str]): + """ + Initializes the engine with a directory map of regional exchange endpoints. + Example: {"US-EAST": "https://us.exchange...", "EU-WEST": "https://eu.exchange..."} + """ + self.endpoints = endpoints + + async def _fetch_regional_data(self, session: aiohttp.ClientSession, region: str, url: str) -> Optional[Dict[str, Any]]: + """ + Fetches telemetry metrics from a single regional endpoint protected by a 2500ms non-blocking gate. + """ + start_time = time.monotonic() + try: + logger.debug(f"Dispatching async request to region [{region}] -> {url}") + + # Enforce strict 2500ms timeout bounds on the network call coroutine + async with async_timeout(REGIONAL_TIMEOUT_SECONDS): + async with session.get(url, allow_redirects=True) as response: + if response.status == 200: + data = await response.json() + latency = (time.monotonic() - start_time) * 1000 + logger.info(f"Successful fetch from region [{region}] in {latency:.2f}ms") + return {"region": region, "status": "SUCCESS", "payload": data} + + logger.warning(f"Region [{region}] returned unsafe response status: {response.status}") + return {"region": region, "status": "ERROR", "code": response.status} + + except asyncio.TimeoutError: + duration = (time.monotonic() - start_time) * 1000 + logger.error(f"Execution boundary breached! Region [{region}] timed out after {duration:.2f}ms (Limit: 2500ms)") + return {"region": region, "status": "TIMEOUT", "error": "2500ms threshold bound breached"} + + except aiohttp.ClientError as e: + logger.error(f"Transport connectivity breakdown for region [{region}]: {str(e)}") + return {"region": region, "status": "TRANSPORT_FAILURE", "error": str(e)} + + except Exception as e: + logger.error(f"Uncaught intercept failure inside coroutine pool for region [{region}]: {str(e)}") + return {"region": region, "status": "INTERNAL_EXCEPTION", "error": str(e)} + + async def poll_all_regions_concurrently(self) -> List[Dict[str, Any]]: + """ + Orchestrates parallel non-blocking evaluation of all regional endpoints. + Slow routes are safely dropped without stalling processing cycles for healthy paths. + """ + start_time = time.monotonic() + logger.info(f"Initializing concurrent poll cycle across {len(self.endpoints)} endpoints...") + + # Configure connection limits to optimize socket pool usage + connector = aiohttp.TCPConnector(limit=50, ttl_dns_cache=300) + + async with aiohttp.ClientSession(connector=connector) as session: + # Build the task array list mapping out regional targets + tasks = [ + self._fetch_regional_data(session, region, url) + for region, url in self.endpoints.items() + ] + + # Trigger a non-blocking gather execution, harvesting results as a block + results = await asyncio.gather(*tasks, return_exceptions=False) + + total_duration = (time.monotonic() - start_time) * 1000 + logger.info(f"Completed concurrent polling cycle in {total_duration:.2f}ms total.") + return list(results) + +def async_timeout(seconds: float): + """Utility abstraction tracking unified async timeout parameters across Python runtimes.""" + return asyncio.timeout(seconds) if hasattr(asyncio, 'timeout') else asyncio.wait_for + """network/polling.py – Bounded asynchronous task collections for periodic price checks. Background polling loops must not spawn unchecked, floating ``asyncio.create_task`` diff --git a/src/utils/random.ts b/src/utils/random.ts new file mode 100644 index 00000000..8c3565ed --- /dev/null +++ b/src/utils/random.ts @@ -0,0 +1,24 @@ +export function randomFloat( + min: number, + max: number, + decimals = 2, +) { + return Number( + ( + Math.random() * + (max - min) + + min + ).toFixed(decimals), + ); +} + +export function randomChoice( + values: T[], +): T { + return values[ + Math.floor( + Math.random() * + values.length, + ) + ]; +} \ No newline at end of file