diff --git a/African API.md b/African API.md index 3c25a05e..8d05338b 100644 --- a/African API.md +++ b/African API.md @@ -7,9 +7,9 @@ This pull request integrates a Nigerian Naira (NGN) exchange rate fetcher into t ### Market Rate Service Improvements - **New NGNRateFetcher**: Added `src/services/marketRate/ngnFetcher.ts` which implements the `MarketRateFetcher` interface. It uses a tiered strategy for robustness: - - **Binance P2P**: Primary source for direct XLM/NGN or XLM/USDT * USDT/NGN cross rates. - - **CoinGecko**: Reliable aggregator for XLM/NGN. - - **ExchangeRate API**: Fallback source for USD/NGN cross-conversion. + - **Binance P2P**: Primary source for direct XLM/NGN or XLM/USDT \* USDT/NGN cross rates. + - **CoinGecko**: Reliable aggregator for XLM/NGN. + - **ExchangeRate API**: Fallback source for USD/NGN cross-conversion. - **Service Registration**: Updated `src/services/marketRate/index.ts` and `src/services/marketRate/marketRateService.ts` to include and register the `NGNRateFetcher`. - **Fault Tolerance**: The new fetcher includes a `CircuitBreaker` and exponential backoff retry logic to handle transient API failures. diff --git a/MULTI_SIG_ARCHITECTURE.md b/MULTI_SIG_ARCHITECTURE.md index 54fe5f92..bb214c35 100644 --- a/MULTI_SIG_ARCHITECTURE.md +++ b/MULTI_SIG_ARCHITECTURE.md @@ -9,11 +9,13 @@ This document provides a technical overview of the multi-sig implementation and ### 1. Separation of Concerns **Before:** + - Single StellarService handling all transaction submission - Direct submission without intermediate approval layer - No separation between signing and submission **After:** + - **MultiSigService**: Handles signature collection and aggregation - **MultiSigSubmissionService**: Background job for approval → submission - **StellarService**: Enhanced with multi-sig support while maintaining single-sig capabilities @@ -22,6 +24,7 @@ This document provides a technical overview of the multi-sig implementation and ### 2. Asynchronous Signature Collection **Key Optimization:** + ```typescript // Non-blocking signature requests private async requestRemoteSignaturesAsync( @@ -31,6 +34,7 @@ private async requestRemoteSignaturesAsync( ``` **Benefits:** + - Price fetches complete immediately (not blocked waiting for remote signatures) - Signatures collected in parallel via `Promise.allSettled()` - Failures on one server don't affect others @@ -39,17 +43,19 @@ private async requestRemoteSignaturesAsync( ### 3. Database Schema for Audit Trail **New Models:** + ``` MultiSigPrice (tracks approval state) ↓ MultiSigSignature (individual signer records) - + Links to existing: - PriceReviewService records (for approval history) - OnChainPrice records (for confirmation) ``` **Audit Benefits:** + - Complete signature history retained - Signer identity recorded (public key + name) - Timestamp for each signature @@ -58,6 +64,7 @@ Links to existing: ### 4. Network Communication Pattern **Server-to-Server Communication:** + ``` Server A (Primary) ├─ Fetches rate @@ -78,6 +85,7 @@ Server A ``` **Optimizations:** + - HTTP endpoints for synchronous signing requests - Authorization via shared token - Deterministic message format ensures signature compatibility @@ -88,6 +96,7 @@ Server A ### 1. Multi-Sig Transaction Construction **Before:** Single signature per transaction + ``` Transaction ├─ Operations: ManageData (price update) @@ -96,6 +105,7 @@ Transaction ``` **After:** Multiple signatures from different servers + ``` Transaction ├─ Operations: ManageData (price update) @@ -107,6 +117,7 @@ Transaction ``` **Benefits:** + - Soroban contract can verify multiple signatures - Authorizes price updates only when multiple signers agree - Prevents single server compromise @@ -124,6 +135,7 @@ submitMultiSignedPriceUpdate( ``` **Process:** + 1. Build transaction (single sequence account) 2. Sign with local keypair 3. Convert to XDR envelope @@ -134,11 +146,13 @@ submitMultiSignedPriceUpdate( ### 3. Fee Optimization for Multi-Sig **Current Implementation:** + - Uses median fee (p50) from Horizon fee_stats - Applies fee increment multiplier on retry: 50% per attempt - Configurable retry limit (default: 3) **Multi-Sig Consideration:** + - Fee calculated per attempt (accounts for all signatures) - Fixed fee amount doesn't change with signature count - Stellar charges per operation, not per signature @@ -148,11 +162,13 @@ submitMultiSignedPriceUpdate( ### Time Complexity **Single Signature:** + ``` Fetch (~100ms) → Review (instant) → Sign (instant) → Submit (~2s) = ~2.1s ``` **Multi-Signature:** + ``` Fetch (~100ms) → Review (instant) → Sign (instant) + Request Remote (~500ms-5s) ↓ (async, non-blocking) @@ -166,11 +182,13 @@ Submit (~2s) = ~2.6s average (if remote responds quickly) ### Space Complexity **Database:** + - MultiSigPrice: ~500 bytes per record - MultiSigSignature: ~1KB per signature - Low space impact: signature aggregation is temporary **Network:** + - Multi-sig doesn't increase transaction size significantly - Envelope signatures are compact (64 bytes each) - Stellar network handles multi-sig natively @@ -178,12 +196,14 @@ Submit (~2s) = ~2.6s average (if remote responds quickly) ## Configuration Flexibility ### Mode 1: Legacy Single-Signature + ```bash # Default behavior - prices submitted immediately MULTI_SIG_ENABLED=false ``` ### Mode 2: Multi-Signature with 2 Servers + ```bash MULTI_SIG_ENABLED=true MULTI_SIG_REQUIRED_COUNT=2 @@ -191,6 +211,7 @@ REMOTE_ORACLE_SERVERS=http://oracle-2.internal:3000 ``` ### Mode 3: Multi-Signature with 3+ Servers + ```bash MULTI_SIG_ENABLED=true MULTI_SIG_REQUIRED_COUNT=3 @@ -202,6 +223,7 @@ REMOTE_ORACLE_SERVERS=http://oracle-2.internal:3000,http://oracle-3.internal:300 ### Signature Request Failures **Scenario:** Remote server is down + ``` Action: Request fails silently (logged as warning) Result: MultiSigPrice waits for timeout (1 hour) @@ -213,6 +235,7 @@ After: Cleaned up as EXPIRED ### Partial Signatures **Scenario:** 1 of 2 signatures collected before expiration + ``` Action: MultiSigPrice remains PENDING Result: Background job skips (not APPROVED) @@ -224,6 +247,7 @@ After: Cleaned up as EXPIRED after 1 hour ### Stellar Submission Failures **Scenario:** Multi-sig transaction fails fee validation + ``` Action: StellarService retries with 50% fee increase (up to 3x) Result: Eventually succeeds or throws after max retries @@ -236,6 +260,7 @@ Result: Eventually succeeds or throws after max retries ### 1. Signature Determinism Using deterministic message format ensures: + ``` All servers sign identical message: "SF-PRICE-NGN-1234.56-CoinGecko" @@ -247,7 +272,7 @@ Any difference (rate, currency, source) results in incompatible signatures. ```typescript // Each signature has a "hint" derived from signer's public key -Keypair.fromPublicKey(signerPublicKey).signatureHint() +Keypair.fromPublicKey(signerPublicKey).signatureHint(); ``` Stellar network verifies each signature matches its public key. @@ -255,6 +280,7 @@ Stellar network verifies each signature matches its public key. ### 3. Network Security Recommendations for production: + - Use HTTPS for all server-to-server communication - Implement VPN or private network for inter-server calls - Use short-lived authorization tokens @@ -289,6 +315,7 @@ Recommendations for production: ### Logging Service includes detailed logging at each step: + ``` [MultiSig] Created signature request 123 for NGN rate 1234.56 [MultiSig] Added signature 1/2 for MultiSigPrice 123 @@ -299,23 +326,26 @@ Service includes detailed logging at each step: ## Testing Strategy ### Unit Tests + - MultiSigService signature aggregation - Message format determinism - Expiration logic ### Integration Tests + - Server-to-server communication - Multi-sig transaction submission - Error recovery scenarios ### Load Tests + - Concurrent price updates - Remote server latency impact - Signature queue handling ## Deployment Checklist -- [ ] Update `.env` with MULTI_SIG_* variables +- [ ] Update `.env` with MULTI*SIG*\* variables - [ ] Run `prisma migrate` to create new tables - [ ] Update all oracle servers with same MULTI_SIG_AUTH_TOKEN - [ ] Configure REMOTE_ORACLE_SERVERS on each server @@ -327,6 +357,7 @@ Service includes detailed logging at each step: ## Backward Compatibility ✅ **Fully Compatible** + - Existing single-sig code continues to work - Feature gates based on `MULTI_SIG_ENABLED` env var - No breaking changes to existing APIs @@ -335,16 +366,21 @@ Service includes detailed logging at each step: ## Future Optimizations ### 1. Signature Caching + Pre-fetch and cache recent signatures to reduce latency ### 2. Weighted Voting + Support 2-of-3 or other threshold combinations ### 3. Parallel Submission + Submit approved prices in batches ### 4. WebSocket Updates + Real-time multi-sig status via Socket.io ### 5. Distributed Consensus + Byzantine Fault Tolerance for > 2 signers diff --git a/MULTI_SIG_GUIDE.md b/MULTI_SIG_GUIDE.md index 3bd5424e..f141509a 100644 --- a/MULTI_SIG_GUIDE.md +++ b/MULTI_SIG_GUIDE.md @@ -41,7 +41,6 @@ Three new models added to `prisma/schema.prisma`: - **MultiSigPrice**: Tracks multi-sig price approval requests - Stores currency, rate, required/collected signatures - Tracks expiration and submission status - - **MultiSigSignature**: Individual signatures from signers - Records signer identity and timestamp - Stores signature in hex format @@ -81,6 +80,7 @@ ORACLE_SIGNER_NAME=oracle-server-1 ### Example Multi-Server Setup **Server 1 (Primary):** + ```bash ORACLE_SECRET_KEY=SBXXX... MULTI_SIG_ENABLED=true @@ -91,6 +91,7 @@ ORACLE_SIGNER_NAME=oracle-primary ``` **Server 2 (Secondary):** + ```bash ORACLE_SECRET_KEY=SBYYY... MULTI_SIG_ENABLED=true @@ -132,22 +133,26 @@ ORACLE_SIGNER_NAME=oracle-secondary ### Detailed Steps #### Step 1: Price Assessment + - MarketRateService fetches rate - PriceReviewService evaluates for anomalies - If manual review needed → PENDING - If approved → proceed to multi-sig #### Step 2: Multi-Sig Request Creation + - MultiSigService creates MultiSigPrice record - Sets required signature count (default: 2) - Sets expiration time (1 hour) #### Step 3: Local Signing + - Local server immediately signs the price update - Signature stored in MultiSigSignature table - Collected signatures incremented #### Step 4: Remote Signature Requests (Async) + ```javascript // Non-blocking request to remote servers // Each remote server independently signs @@ -163,6 +168,7 @@ POST /api/price-updates/sign ``` #### Step 5: Signature Aggregation + - Remote servers receive request on their `/api/price-updates/sign` endpoint - They verify authorization token - They sign the price data (deterministic message format) @@ -170,12 +176,14 @@ POST /api/price-updates/sign - Requesting server records remote signature #### Step 6: Approval & Submission + - Once all signatures collected → MultiSigPrice.status = "APPROVED" - MultiSigSubmissionService polls for APPROVED prices - All signatures added to Stellar transaction - Transaction submitted with multiple signatures #### Step 7: Confirmation + - After confirmation on Stellar: - MultiSigPrice.submittedAt set - Linked PriceReviewService record marked as SUBMITTED @@ -184,6 +192,7 @@ POST /api/price-updates/sign ## API Endpoints ### Create Multi-Sig Request + ```http POST /api/price-updates/multi-sig/request Content-Type: application/json @@ -209,6 +218,7 @@ Response: ``` ### Submit Signature (Remote Server) + ```http POST /api/price-updates/sign Authorization: Bearer your-secure-auth-token @@ -236,6 +246,7 @@ Response: ``` ### Get Multi-Sig Status + ```http GET /api/price-updates/multi-sig/456/status @@ -267,6 +278,7 @@ Response: ``` ### Get Signed Transaction (After Approval) + ```http GET /api/price-updates/multi-sig/456/signatures @@ -294,6 +306,7 @@ Response: ``` ### Get Signer Info + ```http GET /api/price-updates/multi-sig/signer-info @@ -308,6 +321,7 @@ Response: ``` ### List Pending Multi-Sig Prices + ```http GET /api/price-updates/multi-sig/pending @@ -330,6 +344,7 @@ Response: ``` ### Record Submission + ```http POST /api/price-updates/multi-sig/456/record-submission Content-Type: application/json @@ -348,33 +363,40 @@ Response: ## Security Considerations ### 1. Authentication + - All inter-server communication requires `MULTI_SIG_AUTH_TOKEN` - Implement HTTPS in production for all server-to-server calls - Use unique token per deployment environment ### 2. Signature Verification + - Deterministic message format ensures all servers sign same data - Format: `SF-PRICE---` - Public keys verified on Stellar network ### 3. Expiration + - Multi-sig requests expire after 1 hour by default - Prevents old signatures from being used - Background cleanup job removes expired records ### 4. Rate Limiting + - Consider implementing rate limiting on `POST /api/price-updates/sign` - Prevents signature endpoint from being abused ## Fallback Modes ### Disabling Multi-Sig + Set `MULTI_SIG_ENABLED=false` to revert to single-signature mode: + - Prices submitted immediately after approval - No remote signature requests - Direct submission to Stellar ### Legacy Compatibility + - Code maintains backward compatibility - Existing single-sig endpoints still function - Can use multi-sig URLs even if disabled (returns appropriate errors) @@ -382,7 +404,9 @@ Set `MULTI_SIG_ENABLED=false` to revert to single-signature mode: ## Monitoring & Debugging ### Check Multi-Sig Service Status + Monitor logs for: + ``` [MultiSig] Created signature request 123 for NGN rate 1234.56 [MultiSig] Added signature 2/2 for MultiSigPrice 123 @@ -393,35 +417,41 @@ Monitor logs for: ### Database Queries Find pending multi-sig prices: + ```sql SELECT * FROM "MultiSigPrice" WHERE status = 'PENDING'; ``` Check signatures for a price: + ```sql SELECT * FROM "MultiSigSignature" WHERE "multiSigPriceId" = 123; ``` Find expired requests: + ```sql -SELECT * FROM "MultiSigPrice" +SELECT * FROM "MultiSigPrice" WHERE status = 'PENDING' AND "expiresAt" < NOW(); ``` ### Common Issues **Issue: Remote signature requests failing** + - Verify `REMOTE_ORACLE_SERVERS` URLs are correct - Check `MULTI_SIG_AUTH_TOKEN` matches on all servers - Ensure network connectivity between servers - Check firewall rules allow inter-server communication **Issue: Signatures not being collected** + - Verify remote server's `/api/price-updates/sign` endpoint is working - Check logs for "Failed to request signature from..." - Ensure `MULTI_SIG_ENABLED=true` on remote server **Issue: Prices stuck in PENDING status** + - Check if required signature count is too high - Verify all remote servers are running and healthy - Check expiration times @@ -429,6 +459,7 @@ WHERE status = 'PENDING' AND "expiresAt" < NOW(); ## Performance Tuning ### Polling Interval + ```bash # Check for approved prices every 10 seconds (faster) MULTI_SIG_POLL_INTERVAL_MS=10000 @@ -438,11 +469,14 @@ MULTI_SIG_POLL_INTERVAL_MS=60000 ``` ### Signature Expiration + Current: 1 hour (3600000 ms) Adjust in `MultiSigService` constructor if needed ### Database Indexes + All multi-sig tables have appropriate indexes: + - `multiSigPrice(status, expiresAt)` - `multiSigSignature(multiSigPriceId)` diff --git a/MULTI_SIG_IMPLEMENTATION.md b/MULTI_SIG_IMPLEMENTATION.md index 39802e87..e2f6af83 100644 --- a/MULTI_SIG_IMPLEMENTATION.md +++ b/MULTI_SIG_IMPLEMENTATION.md @@ -9,6 +9,7 @@ The StellarFlow backend has been upgraded to support multi-signature (multi-sig) ### 1. Core Services #### MultiSigService (`src/services/multiSigService.ts`) + - **Purpose**: Orchestrates multi-sig price approval flow - **Key Features**: - Creates multi-sig price requests @@ -18,6 +19,7 @@ The StellarFlow backend has been upgraded to support multi-signature (multi-sig) - Communicates with remote oracle servers via HTTP #### MultiSigSubmissionService (`src/services/multiSigSubmissionService.ts`) + - **Purpose**: Background job for approved price submission - **Key Features**: - Periodically polls for approved multi-sig prices @@ -51,7 +53,7 @@ New REST API endpoints for multi-sig operations: - `POST /api/price-updates/multi-sig/request` - Create multi-sig request - `POST /api/price-updates/sign` - Remote server signs (called by peer servers) - `GET /api/price-updates/multi-sig/:id/status` - Get approval status -- `GET /api/price-updates/multi-sig/:id/signatures` - Get collected signatures +- `GET /api/price-updates/multi-sig/:id/signatures` - Get collected signatures - `GET /api/price-updates/multi-sig/pending` - List pending prices - `GET /api/price-updates/multi-sig/signer-info` - Get this server's signer identity - `POST /api/price-updates/multi-sig/:id/record-submission` - Record Stellar submission @@ -59,12 +61,14 @@ New REST API endpoints for multi-sig operations: ### 4. Enhanced Services #### MarketRateService (`src/services/marketRate/marketRateService.ts`) + - **New**: Multi-sig workflow support - **New**: Asynchronous remote signature requests - **New**: Configuration-based mode selection (single-sig vs multi-sig) - Maintains backward compatibility with single-sig mode #### StellarService (`src/services/stellarService.ts`) + - **New**: `submitMultiSignedPriceUpdate()` method - Accepts multiple signatures and adds them to transaction - Maintains existing fee bumping and retry logic @@ -89,32 +93,32 @@ MULTI_SIG_POLL_INTERVAL_MS=30000 # Background job polling interval ``` 1. Price Fetched └─ MarketRateService.getRate() - + 2. Price Reviewed └─ PriceReviewService.assessRate() → AUTO_APPROVED or PENDING - + 3. Multi-Sig Request Created (if MULTI_SIG_ENABLED && AUTO_APPROVED) └─ MultiSigService.createMultiSigRequest() - + 4. Local Signing └─ MultiSigService.signMultiSigPrice() → records local signature - + 5. Remote Signature Requests Sent (asynchronous, non-blocking) └─ MultiSigService.requestRemoteSignature() → HTTP POST to remote servers - + 6. Remote Servers Sign └─ Remote server's /api/price-updates/sign endpoint └─ Returns signature - + 7. Signatures Aggregated └─ Once all collected → MultiSigPrice.status = "APPROVED" - + 8. Background Job Detects Approved Price └─ MultiSigSubmissionService.checkAndSubmitApprovedPrices() - + 9. Multi-Signed Transaction Submitted to Stellar └─ StellarService.submitMultiSignedPriceUpdate() - + 10. Confirmation Recorded └─ MultiSigService.recordSubmission() └─ OnChainPrice record created by SorobanEventListener @@ -123,12 +127,14 @@ MULTI_SIG_POLL_INTERVAL_MS=30000 # Background job polling interval ## Key Optimizations ### 1. Non-Blocking Signature Requests + - Price fetch returns immediately - Remote signature requests happen asynchronously - Background job handles eventual submission - Failures on one server don't block others ### 2. Deterministic Signing + - All servers sign the same message: ``` "SF-PRICE-NGN-1234.56-CoinGecko" @@ -137,11 +143,13 @@ MULTI_SIG_POLL_INTERVAL_MS=30000 # Background job polling interval - Prevents signature mismatches ### 3. Expiration Management + - 1-hour expiration window prevents stale signatures - Background job periodically cleans up expired records - Prevents accidental reuse of old signatures ### 4. Efficient Database Schema + - Proper indexing on status, dates, and relationships - Supports high-frequency price updates - Audit trail maintained for all signatures @@ -149,6 +157,7 @@ MULTI_SIG_POLL_INTERVAL_MS=30000 # Background job polling interval ## Deployment Steps 1. **Update Environment Variables** + ```bash MULTI_SIG_ENABLED=true MULTI_SIG_REQUIRED_COUNT=2 @@ -158,11 +167,13 @@ MULTI_SIG_POLL_INTERVAL_MS=30000 # Background job polling interval ``` 2. **Run Database Migration** + ```bash npm run db:migrate ``` 3. **Restart Services** + ```bash npm run start ``` @@ -179,12 +190,14 @@ MULTI_SIG_POLL_INTERVAL_MS=30000 # Background job polling interval Three comprehensive guides have been created: ### MULTI_SIG_QUICKSTART.md + - 5-minute setup guide - Manual testing procedures - Troubleshooting common issues - Production readiness checklist ### MULTI_SIG_GUIDE.md + - Complete feature documentation - Workflow explanation - API endpoint reference @@ -193,6 +206,7 @@ Three comprehensive guides have been created: - Future enhancements ### MULTI_SIG_ARCHITECTURE.md + - Technical architecture deep dive - Component interactions - Optimization rationale @@ -203,6 +217,7 @@ Three comprehensive guides have been created: ## Testing ### Manual Testing + ```bash # Request a price (will use multi-sig if enabled) curl http://localhost:3000/api/market-rates/rate/NGN @@ -218,6 +233,7 @@ curl http://localhost:3000/api/price-updates/multi-sig/123/signatures ``` ### Recommended Tests + - [ ] Single-server multi-sig (local signing only) - [ ] Two-server multi-sig (full flow) - [ ] Remote server down scenario @@ -228,6 +244,7 @@ curl http://localhost:3000/api/price-updates/multi-sig/123/signatures ## Files Modified/Created ### New Files + - `src/services/multiSigService.ts` - Multi-sig coordination - `src/services/multiSigSubmissionService.ts` - Background submission job - `src/routes/priceUpdates.ts` - Multi-sig API endpoints @@ -236,6 +253,7 @@ curl http://localhost:3000/api/price-updates/multi-sig/123/signatures - `MULTI_SIG_QUICKSTART.md` - Quick start guide ### Modified Files + - `prisma/schema.prisma` - Added 3 new models - `src/index.ts` - Imported and started multi-sig services - `src/services/marketRate/marketRateService.ts` - Integrated multi-sig workflow @@ -245,6 +263,7 @@ curl http://localhost:3000/api/price-updates/multi-sig/123/signatures ## Backward Compatibility ✅ **Fully Backward Compatible** + - Existing single-sig code continues to work - Feature disabled by default (`MULTI_SIG_ENABLED=false`) - No breaking changes to existing APIs @@ -268,6 +287,7 @@ curl http://localhost:3000/api/price-updates/multi-sig/123/signatures ## Support & Troubleshooting See the guide documents for: + - Common issues and solutions - Logging and debugging - Performance optimization @@ -276,6 +296,7 @@ See the guide documents for: - Architecture details For issues: + 1. Check application logs for `[MultiSig]` messages 2. Query database for MultiSigPrice/MultiSigSignature records 3. Verify network connectivity between servers diff --git a/MULTI_SIG_QUICKSTART.md b/MULTI_SIG_QUICKSTART.md index 5c0dfc8c..ab237837 100644 --- a/MULTI_SIG_QUICKSTART.md +++ b/MULTI_SIG_QUICKSTART.md @@ -3,11 +3,13 @@ ## 5-Minute Setup for 2-Server Multi-Sig ### Prerequisites + - 2 StellarFlow backend instances running - Both connected to same database - Stellar TESTNET accounts set up ### Step 1: Generate Shared Token + ```bash # Generate a random token (or use your own) openssl rand -hex 32 @@ -15,6 +17,7 @@ openssl rand -hex 32 ``` ### Step 2: Update Server 1 (.env) + ```bash # Price Update Configuration MULTI_SIG_ENABLED=true @@ -26,6 +29,7 @@ MULTI_SIG_POLL_INTERVAL_MS=30000 ``` ### Step 3: Update Server 2 (.env) + ```bash # Price Update Configuration MULTI_SIG_ENABLED=true @@ -37,16 +41,20 @@ MULTI_SIG_POLL_INTERVAL_MS=30000 ``` ### Step 4: Run Migrations + On each server: + ```bash npm run db:migrate ``` This creates the new tables: + - `MultiSigPrice` - `MultiSigSignature` ### Step 5: Restart Services + ```bash # Server 1 npm run start # or: npm run dev @@ -56,6 +64,7 @@ npm run start # or: npm run dev ``` Watch logs for: + ``` [MarketRateService] Multi-Sig mode ENABLED with 1 remote servers [MultiSigSubmissionService] Started with 30000ms poll interval @@ -65,6 +74,7 @@ Watch logs for: ## Testing Multi-Sig ### Manual Test: Request a Price + ```bash # Request NGN price (will trigger multi-sig flow) curl http://localhost:3000/api/market-rates/rate/NGN @@ -84,6 +94,7 @@ curl http://localhost:3000/api/market-rates/rate/NGN ``` ### Check Pending Multi-Sig Prices + ```bash curl http://localhost:3000/api/price-updates/multi-sig/pending @@ -105,6 +116,7 @@ curl http://localhost:3000/api/price-updates/multi-sig/pending ``` ### Monitor Status Until Approval + ```bash # Check status of price #123 curl http://localhost:3000/api/price-updates/multi-sig/123/status @@ -114,6 +126,7 @@ curl http://localhost:3000/api/price-updates/multi-sig/123/status ``` ### Verify Multi-Sig Submission + ```bash # Once APPROVED, check if signatures are ready curl http://localhost:3000/api/price-updates/multi-sig/123/signatures @@ -140,13 +153,14 @@ curl http://localhost:3000/api/price-updates/multi-sig/123/signatures ``` ### Check Stellar Submission + ```bash # After background job submits to Stellar (check OnChainPrice table) # Price should appear in market rates with stellarTxHash set -SELECT * FROM "OnChainPrice" -WHERE currency = 'NGN' -ORDER BY "confirmedAt" DESC +SELECT * FROM "OnChainPrice" +WHERE currency = 'NGN' +ORDER BY "confirmedAt" DESC LIMIT 1; # Should see the multi-signed transaction @@ -157,12 +171,14 @@ LIMIT 1; ### Issue: Multi-Sig requests not being signed **Check 1: Is multi-sig enabled on both servers?** + ```bash # Server logs should show: [MarketRateService] Multi-Sig mode ENABLED with 1 remote servers ``` **Check 2: Can servers reach each other?** + ```bash # From Server 1, test connectivity to Server 2: curl http://server2.internal:3000/api/price-updates/multi-sig/signer-info @@ -171,6 +187,7 @@ curl http://server2.internal:3000/api/price-updates/multi-sig/signer-info ``` **Check 3: Is auth token correct?** + ```bash # Check error in logs: [API] Signature creation failed: Unauthorized - invalid token @@ -181,6 +198,7 @@ curl http://server2.internal:3000/api/price-updates/multi-sig/signer-info ### Issue: Signatures stuck at 1/2 **Check database:** + ```bash SELECT * FROM "MultiSigSignature" WHERE "multiSigPriceId" = 123; @@ -189,6 +207,7 @@ SELECT * FROM "MultiSigSignature" WHERE "multiSigPriceId" = 123; ``` **Check logs for:** + ``` [MarketRateService] ⚠️ Signature request failed for http://server2.internal:3000 [MarketRateService] ❌ Error requesting signature from http://server2.internal:3000 @@ -197,12 +216,14 @@ SELECT * FROM "MultiSigSignature" WHERE "multiSigPriceId" = 123; ### Issue: Background job not submitting approved prices **Check 1: Is submission service running?** + ```bash # Logs should show: [MultiSigSubmissionService] Started with 30000ms poll interval ``` **Check 2: Are there approved prices?** + ```bash SELECT * FROM "MultiSigPrice" WHERE status = 'APPROVED'; @@ -211,6 +232,7 @@ SELECT * FROM "MultiSigPrice" WHERE status = 'APPROVED'; ``` **Check 3: Stellar submission failures** + ``` [MultiSigSubmissionService] Failed to submit multi-sig price 123: ... @@ -220,6 +242,7 @@ SELECT * FROM "MultiSigPrice" WHERE status = 'APPROVED'; ## Switching Modes ### Enable Multi-Sig (from single-sig) + 1. Update `.env` with `MULTI_SIG_ENABLED=true` 2. Configure `REMOTE_ORACLE_SERVERS` 3. Run `prisma migrate` @@ -227,6 +250,7 @@ SELECT * FROM "MultiSigPrice" WHERE status = 'APPROVED'; 5. New prices will use multi-sig flow ### Disable Multi-Sig (revert to single-sig) + 1. Update `.env` with `MULTI_SIG_ENABLED=false` 2. Restart services 3. Existing pending multi-sig prices won't be submitted @@ -253,6 +277,7 @@ Before deploying to production: ## Support For issues, check: + 1. `/workspaces/stellarflow-backend/MULTI_SIG_GUIDE.md` - Comprehensive guide 2. `/workspaces/stellarflow-backend/MULTI_SIG_ARCHITECTURE.md` - Technical details 3. Application logs - Most issues visible in detailed logs diff --git a/TODO.md b/TODO.md index d41f4893..9a00165f 100644 --- a/TODO.md +++ b/TODO.md @@ -3,27 +3,34 @@ ## Approved Plan Steps ### 1. [x] Create src/logic/outlierFilter.ts - - Implement filterOutliers() with IQR method - - Add isOutlier() helper + +- Implement filterOutliers() with IQR method +- Add isOutlier() helper ### 2. [x] Update src/services/marketRate/types.ts - - Re-export filterOutliers + +- Re-export filterOutliers ### 3. [x] Update ghsFetcher.ts - - Import filterOutliers - - Filter rateValues before calculateMedian + +- Import filterOutliers +- Filter rateValues before calculateMedian ### 4. [x] Update kesFetcher.ts - - Import filterOutliers - - Filter in fetchFromBinance() and other price collection points + +- Import filterOutliers +- Filter in fetchFromBinance() and other price collection points ### 5. [x] Update ngnFetcher.ts - - Import filterOutliers - - Filter rateValues before calculateMedian + +- Import filterOutliers +- Filter rateValues before calculateMedian ### 6. [x] Test changes - - Run npm test - - Manual test: import MarketRateService and call getRate('GHS') + +- Run npm test +- Manual test: import MarketRateService and call getRate('GHS') ### 7. [x] Complete - - attempt_completion + +- attempt_completion diff --git a/Transaction memo.md b/Transaction memo.md index f4daa423..67eb5293 100644 --- a/Transaction memo.md +++ b/Transaction memo.md @@ -5,25 +5,33 @@ This feature implements unique ID tagging for every price update submitted to th ## Implementation Overview ### 1. StellarService + A new service (`src/services/stellarService.ts`) has been added to handle all interactions with the Stellar network. It encapsulates: + - **Transaction Building**: Uses `@stellar/stellar-sdk` to create transactions. - **ManageData Operations**: Prices are submitted as `manageData` operations (e.g., `NGN_PRICE`). - **Memo Tagging**: Each transaction includes a `MemoText` with a unique ID formatted as `SF---`. - **Dynamic Network Selection**: Supports both `TESTNET` and `PUBLIC` networks via environment variables. ### 2. MarketRateService Integration + The `MarketRateService` now utilizes the `StellarService` to broadcast price updates: + - Every time a fresh rate is fetched (not from cache), the service triggers a background update to the Stellar network. - Failures in the Stellar broadcast are logged but do not block the API response, ensuring high availability. ## Benefits for Auditing + With unique memo IDs, auditors can: + - Filter transactions by the `SF-` prefix on StellarExpert. - Correlate specific price points with their corresponding timestamps and sources. - Verify the frequency and consistency of price updates directly on the blockchain. ## Configuration + To enable the Stellar integration, ensure the following environment variables are set: + - `ORACLE_SECRET_KEY` or `SOROBAN_ADMIN_SECRET`: The secret key for the oracle account. - `STELLAR_NETWORK`: `TESTNET` (default) or `PUBLIC`. diff --git a/docker-compose.yml b/docker-compose.yml index 41831f85..fb595c09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: app: diff --git a/scripts/check-gas-balance.ts b/scripts/check-gas-balance.ts index 7dd2b04f..f68421dd 100644 --- a/scripts/check-gas-balance.ts +++ b/scripts/check-gas-balance.ts @@ -76,4 +76,4 @@ async function checkGasAccountBalance(): Promise { checkGasAccountBalance().catch((error) => { console.error("Failed to check Gas Account balance:", error); process.exit(1); -}); \ No newline at end of file +}); diff --git a/scripts/download-ngn-rates.ts b/scripts/download-ngn-rates.ts index 8d09feea..64dca4cc 100644 --- a/scripts/download-ngn-rates.ts +++ b/scripts/download-ngn-rates.ts @@ -10,7 +10,9 @@ async function downloadNGNRates() { const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - console.log(`Fetching NGN rates from ${thirtyDaysAgo.toISOString()} to now...`); + console.log( + `Fetching NGN rates from ${thirtyDaysAgo.toISOString()} to now...`, + ); // Query PriceHistory for NGN rates in the last 30 days const rates: PriceHistory[] = await prisma.priceHistory.findMany({ @@ -34,27 +36,32 @@ async function downloadNGNRates() { // Convert to CSV format const csvHeader = "timestamp,rate,source\n"; - const csvRows = rates.map((rate) => - `${rate.timestamp.toISOString()},${rate.rate},${rate.source}` + const csvRows = rates.map( + (rate) => `${rate.timestamp.toISOString()},${rate.rate},${rate.source}`, ); const csvContent = csvHeader + csvRows.join("\n"); // Write to file - const filename = `ngn-rates-last-30-days-${new Date().toISOString().split('T')[0]}.csv`; + const filename = `ngn-rates-last-30-days-${new Date().toISOString().split("T")[0]}.csv`; writeFileSync(filename, csvContent); console.log(`NGN rates exported to ${filename}`); console.log(`Total records: ${rates.length}`); // Show some stats - const ratesValues = rates.map((r: PriceHistory) => parseFloat(r.rate.toString())); + const ratesValues = rates.map((r: PriceHistory) => + parseFloat(r.rate.toString()), + ); const minRate = Math.min(...ratesValues); const maxRate = Math.max(...ratesValues); - const avgRate = ratesValues.reduce((sum: number, rate: number) => sum + rate, 0) / ratesValues.length; + const avgRate = + ratesValues.reduce((sum: number, rate: number) => sum + rate, 0) / + ratesValues.length; - console.log(`Rate range: ${minRate.toFixed(2)} - ${maxRate.toFixed(2)} NGN/XLM`); + console.log( + `Rate range: ${minRate.toFixed(2)} - ${maxRate.toFixed(2)} NGN/XLM`, + ); console.log(`Average rate: ${avgRate.toFixed(2)} NGN/XLM`); - } catch (error) { console.error("Error downloading NGN rates:", error); process.exit(1); @@ -64,4 +71,4 @@ async function downloadNGNRates() { } // Run the script -downloadNGNRates(); \ No newline at end of file +downloadNGNRates(); diff --git a/src/controllers/marketRatesController.ts b/src/controllers/marketRatesController.ts index 4c2ab82d..9932ea4d 100644 --- a/src/controllers/marketRatesController.ts +++ b/src/controllers/marketRatesController.ts @@ -35,7 +35,9 @@ export const getRate = async (req: Request, res: Response) => { export const getAllRates = async (req: Request, res: Response) => { try { const results = await marketRateService.getAllRates(); - const rates = results.filter((result) => result.success).map((result) => result.data); + const rates = results + .filter((result) => result.success) + .map((result) => result.data); res.json({ success: true, data: rates, diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index d92b766e..98ada8f4 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -16,7 +16,7 @@ export const prisma = new Proxy({} as PrismaClient, { if (!globalForPrisma.prisma) { // Ensure environment variables are loaded before initialization dotenv.config(); - + const connectionString = process.env.DATABASE_URL; if (!connectionString) { throw new Error("DATABASE_URL must be defined"); diff --git a/src/lib/socket.ts b/src/lib/socket.ts index a15edec8..2257ab19 100644 --- a/src/lib/socket.ts +++ b/src/lib/socket.ts @@ -10,7 +10,7 @@ export function initSocket(server: import("http").Server): Server { io.on("connection", (socket) => { console.log(`🔌 Client connected: ${socket.id}`); socket.on("disconnect", () => - console.log(`🔌 Client disconnected: ${socket.id}`) + console.log(`🔌 Client disconnected: ${socket.id}`), ); }); diff --git a/src/logic/outlierFilter.ts b/src/logic/outlierFilter.ts index b4f7b880..a63b3a55 100644 --- a/src/logic/outlierFilter.ts +++ b/src/logic/outlierFilter.ts @@ -1,24 +1,27 @@ /** * Outlier Detection Filter for Exchange Rates * Detects and removes manipulated/extreme prices using Interquartile Range (IQR) method - * + * * Example: [750, 752, 900] → Q1=750, Q3=752, IQR=2, upper=752+1.5*2=755 → keeps 750,752 (ignores 900 if tuned) */ -export function filterOutliers(prices: number[], multiplier: number = 1.5): number[] { +export function filterOutliers( + prices: number[], + multiplier: number = 1.5, +): number[] { if (prices.length < 3) { // Need at least 3 prices for meaningful outlier detection - return prices.filter(p => p > 0 && !isNaN(p)); + return prices.filter((p) => p > 0 && !isNaN(p)); } - const validPrices = prices.filter(p => p > 0 && !isNaN(p) && isFinite(p)); + const validPrices = prices.filter((p) => p > 0 && !isNaN(p) && isFinite(p)); if (validPrices.length < 3) return validPrices; const sorted = [...validPrices].sort((a, b) => a - b); const n = sorted.length; const q1Index = Math.floor((n + 1) / 4); - const q3Index = Math.floor(3 * (n + 1) / 4); - + const q3Index = Math.floor((3 * (n + 1)) / 4); + const q1 = sorted[q1Index] ?? sorted[0] ?? 0; const q3 = sorted[q3Index] ?? sorted[n - 1] ?? 0; const iqr = q3 - q1; @@ -26,13 +29,19 @@ export function filterOutliers(prices: number[], multiplier: number = 1.5): numb const lowerFence = q1 - multiplier * iqr; const upperFence = q3 + multiplier * iqr; - const filtered = sorted.filter(price => price >= lowerFence && price <= upperFence); - + const filtered = sorted.filter( + (price) => price >= lowerFence && price <= upperFence, + ); + // Fallback: if too few remain (< 2), return all valid return filtered.length >= 2 ? filtered : validPrices; } -export function isOutlier(price: number, prices: number[], multiplier: number = 1.5): boolean { +export function isOutlier( + price: number, + prices: number[], + multiplier: number = 1.5, +): boolean { const filtered = filterOutliers(prices, multiplier); return !filtered.includes(price); } diff --git a/src/middleware/apiKeyMiddleware.ts b/src/middleware/apiKeyMiddleware.ts index 883961c6..ff18da8e 100644 --- a/src/middleware/apiKeyMiddleware.ts +++ b/src/middleware/apiKeyMiddleware.ts @@ -1,23 +1,27 @@ import { Request, Response, NextFunction } from "express"; -export const apiKeyMiddleware = (req: Request, res: Response, next: NextFunction) => { - const apiKey = req.headers["x-api-key"]; - const expectedKey = process.env.API_KEY; +export const apiKeyMiddleware = ( + req: Request, + res: Response, + next: NextFunction, +) => { + const apiKey = req.headers["x-api-key"]; + const expectedKey = process.env.API_KEY; - if (!expectedKey) { - console.error("Critical: API_KEY not set in environment"); - return res.status(500).json({ - success: false, - error: "Authentication configuration error", - }); - } + if (!expectedKey) { + console.error("Critical: API_KEY not set in environment"); + return res.status(500).json({ + success: false, + error: "Authentication configuration error", + }); + } - if (apiKey !== expectedKey) { - return res.status(401).json({ - success: false, - error: "Invalid or missing API key", - }); - } + if (apiKey !== expectedKey) { + return res.status(401).json({ + success: false, + error: "Invalid or missing API key", + }); + } - next(); + next(); }; diff --git a/src/routes/intelligence.ts b/src/routes/intelligence.ts index 2c8c2539..48a6a4ed 100644 --- a/src/routes/intelligence.ts +++ b/src/routes/intelligence.ts @@ -39,10 +39,10 @@ const router = Router(); */ router.get("/price-change/:currency", async (req, res) => { const currency = req.params.currency.toUpperCase(); - + try { const change = await intelligenceService.calculate24hPriceChange(currency); - + res.json({ success: true, currency, @@ -84,7 +84,7 @@ router.get("/price-change/:currency", async (req, res) => { router.get("/stale", async (req, res) => { try { const staleCurrencies = await intelligenceService.getStaleCurrencies(); - + res.json({ success: true, staleCurrencies, diff --git a/src/routes/marketRates.ts b/src/routes/marketRates.ts index 6a4361fa..5665e363 100644 --- a/src/routes/marketRates.ts +++ b/src/routes/marketRates.ts @@ -1,4 +1,3 @@ - import { Router } from "express"; import { getRate, getAllRates } from "../controllers/marketRatesController"; import { MarketRateService } from "../services/marketRate"; @@ -75,8 +74,6 @@ router.get("/rate/:currency", getRate); // Get all available rates router.get("/rates", getAllRates); - - /** * @swagger * /api/v1/market-rates/latest: diff --git a/src/routes/priceUpdates.ts b/src/routes/priceUpdates.ts index 848caee2..0ba37f5f 100644 --- a/src/routes/priceUpdates.ts +++ b/src/routes/priceUpdates.ts @@ -12,10 +12,17 @@ router.post("/multi-sig/request", async (req: Request, res: Response) => { try { const { priceReviewId, currency, rate, source, memoId } = req.body; - if (!priceReviewId || !currency || rate === undefined || !source || !memoId) { + if ( + !priceReviewId || + !currency || + rate === undefined || + !source || + !memoId + ) { return res.status(400).json({ success: false, - error: "Missing required fields: priceReviewId, currency, rate, source, memoId", + error: + "Missing required fields: priceReviewId, currency, rate, source, memoId", }); } @@ -24,7 +31,7 @@ router.post("/multi-sig/request", async (req: Request, res: Response) => { currency, rate, source, - memoId + memoId, ); res.json({ @@ -44,7 +51,7 @@ router.post("/multi-sig/request", async (req: Request, res: Response) => { * POST /api/v1/price-updates/sign * Endpoint for remote servers to request a signature. * This is called by peer servers in the multi-sig setup. - * + * * Requires: * - Authorization header with token (if MULTI_SIG_AUTH_TOKEN is set) * - Signature payload in body @@ -77,9 +84,8 @@ router.post("/sign", async (req: Request, res: Response) => { } // Sign the price update locally - const { signature, signerPublicKey } = await multiSigService.signMultiSigPrice( - multiSigPriceId - ); + const { signature, signerPublicKey } = + await multiSigService.signMultiSigPrice(multiSigPriceId); const signerInfo = multiSigService.getLocalSignerInfo(); @@ -106,91 +112,102 @@ router.post("/sign", async (req: Request, res: Response) => { * Request a signature from a remote server. * The body should contain the remote server URL. */ -router.post("/multi-sig/:multiSigPriceId/request-signature", async (req: Request, res: Response) => { - try { - const multiSigPriceId = req.params.multiSigPriceId; - const { remoteServerUrl } = req.body; +router.post( + "/multi-sig/:multiSigPriceId/request-signature", + async (req: Request, res: Response) => { + try { + const multiSigPriceId = req.params.multiSigPriceId; + const { remoteServerUrl } = req.body; + + if ( + !multiSigPriceId || + typeof multiSigPriceId !== "string" || + !remoteServerUrl + ) { + return res.status(400).json({ + success: false, + error: + "Missing multiSigPriceId (in URL) or remoteServerUrl (in body)", + }); + } - if (!multiSigPriceId || typeof multiSigPriceId !== "string" || !remoteServerUrl) { - return res.status(400).json({ - success: false, - error: "Missing multiSigPriceId (in URL) or remoteServerUrl (in body)", - }); - } + const result = await multiSigService.requestRemoteSignature( + parseInt(multiSigPriceId, 10), + remoteServerUrl, + ); - const result = await multiSigService.requestRemoteSignature( - parseInt(multiSigPriceId, 10), - remoteServerUrl - ); + if (!result.success) { + return res.status(400).json({ + success: false, + error: result.error, + }); + } - if (!result.success) { - return res.status(400).json({ + res.json({ success: true }); + } catch (error) { + console.error("[API] Remote signature request failed:", error); + res.status(500).json({ success: false, - error: result.error, + error: String(error), }); } - - res.json({ success: true }); - } catch (error) { - console.error("[API] Remote signature request failed:", error); - res.status(500).json({ - success: false, - error: String(error), - }); - } -}); + }, +); /** * GET /api/v1/price-updates/multi-sig/:multiSigPriceId/status * Get the status of a multi-sig price update. */ -router.get("/multi-sig/:multiSigPriceId/status", async (req: Request, res: Response) => { - try { - const multiSigPriceId = req.params.multiSigPriceId; +router.get( + "/multi-sig/:multiSigPriceId/status", + async (req: Request, res: Response) => { + try { + const multiSigPriceId = req.params.multiSigPriceId; + + if (!multiSigPriceId || typeof multiSigPriceId !== "string") { + return res.status(400).json({ + success: false, + error: "Missing multiSigPriceId in URL", + }); + } - if (!multiSigPriceId || typeof multiSigPriceId !== "string") { - return res.status(400).json({ - success: false, - error: "Missing multiSigPriceId in URL", - }); - } + const multiSigPrice = await multiSigService.getMultiSigPrice( + parseInt(multiSigPriceId, 10), + ); - const multiSigPrice = await multiSigService.getMultiSigPrice( - parseInt(multiSigPriceId, 10) - ); + if (!multiSigPrice) { + return res.status(404).json({ + success: false, + error: `MultiSigPrice ${multiSigPriceId} not found`, + }); + } - if (!multiSigPrice) { - return res.status(404).json({ + res.json({ + success: true, + data: { + id: multiSigPrice.id, + currency: multiSigPrice.currency, + rate: multiSigPrice.rate, + status: multiSigPrice.status, + collectedSignatures: multiSigPrice.collectedSignatures, + requiredSignatures: multiSigPrice.requiredSignatures, + expiresAt: multiSigPrice.expiresAt, + signers: multiSigPrice.multiSigSignatures?.map((sig: any) => ({ + publicKey: sig.signerPublicKey, + name: sig.signerName, + signedAt: sig.signedAt, + })), + }, + }); + } catch (error) { + console.error("[API] Multi-sig status fetch failed:", error); + res.status(500).json({ success: false, - error: `MultiSigPrice ${multiSigPriceId} not found`, + error: String(error), }); } - - res.json({ - success: true, - data: { - id: multiSigPrice.id, - currency: multiSigPrice.currency, - rate: multiSigPrice.rate, - status: multiSigPrice.status, - collectedSignatures: multiSigPrice.collectedSignatures, - requiredSignatures: multiSigPrice.requiredSignatures, - expiresAt: multiSigPrice.expiresAt, - signers: multiSigPrice.multiSigSignatures?.map((sig: any) => ({ - publicKey: sig.signerPublicKey, - name: sig.signerName, - signedAt: sig.signedAt, - })), - }, - }); - } catch (error) { - console.error("[API] Multi-sig status fetch failed:", error); - res.status(500).json({ - success: false, - error: String(error), - }); - } -}); + }, +); /** * GET /api/v1/price-updates/multi-sig/pending @@ -228,92 +245,104 @@ router.get("/multi-sig/pending", async (req: Request, res: Response) => { * Get all signatures for a multi-sig price update. * Only returns once all signatures are collected and approved. */ -router.get("/multi-sig/:multiSigPriceId/signatures", async (req: Request, res: Response) => { - try { - const multiSigPriceId = req.params.multiSigPriceId; +router.get( + "/multi-sig/:multiSigPriceId/signatures", + async (req: Request, res: Response) => { + try { + const multiSigPriceId = req.params.multiSigPriceId; + + if (!multiSigPriceId || typeof multiSigPriceId !== "string") { + return res.status(400).json({ + success: false, + error: "Missing multiSigPriceId in URL", + }); + } - if (!multiSigPriceId || typeof multiSigPriceId !== "string") { - return res.status(400).json({ - success: false, - error: "Missing multiSigPriceId in URL", - }); - } + const multiSigPrice = await multiSigService.getMultiSigPrice( + parseInt(multiSigPriceId, 10), + ); - const multiSigPrice = await multiSigService.getMultiSigPrice( - parseInt(multiSigPriceId, 10) - ); + if (!multiSigPrice) { + return res.status(404).json({ + success: false, + error: `MultiSigPrice ${multiSigPriceId} not found`, + }); + } - if (!multiSigPrice) { - return res.status(404).json({ - success: false, - error: `MultiSigPrice ${multiSigPriceId} not found`, - }); - } + if (multiSigPrice.status !== "APPROVED") { + return res.status(400).json({ + success: false, + error: `MultiSigPrice ${multiSigPriceId} is not approved yet (status: ${multiSigPrice.status})`, + }); + } - if (multiSigPrice.status !== "APPROVED") { - return res.status(400).json({ + const signatures = await multiSigService.getSignatures( + parseInt(multiSigPriceId, 10), + ); + + res.json({ + success: true, + data: { + multiSigPriceId: multiSigPrice.id, + currency: multiSigPrice.currency, + rate: multiSigPrice.rate, + signatures: signatures.map((sig) => ({ + signerPublicKey: sig.signerPublicKey, + signerName: sig.signerName, + signature: sig.signature, + })), + }, + }); + } catch (error) { + console.error("[API] Signature fetch failed:", error); + res.status(500).json({ success: false, - error: `MultiSigPrice ${multiSigPriceId} is not approved yet (status: ${multiSigPrice.status})`, + error: String(error), }); } - - const signatures = await multiSigService.getSignatures( - parseInt(multiSigPriceId, 10) - ); - - res.json({ - success: true, - data: { - multiSigPriceId: multiSigPrice.id, - currency: multiSigPrice.currency, - rate: multiSigPrice.rate, - signatures: signatures.map((sig) => ({ - signerPublicKey: sig.signerPublicKey, - signerName: sig.signerName, - signature: sig.signature, - })), - }, - }); - } catch (error) { - console.error("[API] Signature fetch failed:", error); - res.status(500).json({ - success: false, - error: String(error), - }); - } -}); + }, +); /** * POST /api/v1/price-updates/multi-sig/:multiSigPriceId/record-submission * Record that a multi-sig price has been submitted to Stellar. */ -router.post("/multi-sig/:multiSigPriceId/record-submission", async (req: Request, res: Response) => { - try { - const multiSigPriceId = req.params.multiSigPriceId; - const { memoId, stellarTxHash } = req.body; +router.post( + "/multi-sig/:multiSigPriceId/record-submission", + async (req: Request, res: Response) => { + try { + const multiSigPriceId = req.params.multiSigPriceId; + const { memoId, stellarTxHash } = req.body; + + if ( + !multiSigPriceId || + typeof multiSigPriceId !== "string" || + !memoId || + !stellarTxHash + ) { + return res.status(400).json({ + success: false, + error: + "Missing required fields: multiSigPriceId (in URL), memoId, stellarTxHash (in body)", + }); + } - if (!multiSigPriceId || typeof multiSigPriceId !== "string" || !memoId || !stellarTxHash) { - return res.status(400).json({ + await multiSigService.recordSubmission( + parseInt(multiSigPriceId, 10), + memoId, + stellarTxHash, + ); + + res.json({ success: true }); + } catch (error) { + console.error("[API] Submission recording failed:", error); + res.status(500).json({ success: false, - error: "Missing required fields: multiSigPriceId (in URL), memoId, stellarTxHash (in body)", + error: String(error), }); } - - await multiSigService.recordSubmission( - parseInt(multiSigPriceId, 10), - memoId, - stellarTxHash - ); - - res.json({ success: true }); - } catch (error) { - console.error("[API] Submission recording failed:", error); - res.status(500).json({ - success: false, - error: String(error), - }); - } -}); + }, +); /** * GET /api/v1/price-updates/multi-sig/signer-info diff --git a/src/routes/stats.ts b/src/routes/stats.ts index 40ced579..bcb71bd0 100644 --- a/src/routes/stats.ts +++ b/src/routes/stats.ts @@ -7,12 +7,10 @@ const router = Router(); router.get("/volume", async (req, res) => { try { const dateParam = req.query.date as string; - + // Default to today if no date provided - const targetDate = dateParam - ? new Date(dateParam) - : new Date(); - + const targetDate = dateParam ? new Date(dateParam) : new Date(); + // Validate date if (isNaN(targetDate.getTime())) { res.status(400).json({ @@ -21,14 +19,14 @@ router.get("/volume", async (req, res) => { }); return; } - + // Set start and end of day (UTC) const startOfDay = new Date(targetDate); startOfDay.setUTCHours(0, 0, 0, 0); - + const endOfDay = new Date(targetDate); endOfDay.setUTCHours(23, 59, 59, 999); - + // Get price history entries for the day const priceHistoryCount = await prisma.priceHistory.count({ where: { @@ -38,7 +36,7 @@ router.get("/volume", async (req, res) => { }, }, }); - + // Get on-chain price entries for the day const onChainPriceCount = await prisma.onChainPrice.count({ where: { @@ -48,7 +46,7 @@ router.get("/volume", async (req, res) => { }, }, }); - + // Get provider requests for the day (from reputation service) const providerStats = await prisma.providerReputation.findMany({ select: { @@ -60,12 +58,21 @@ router.get("/volume", async (req, res) => { lastFailure: true, }, }); - + // Calculate total requests (this is cumulative, not daily) - const totalApiRequests = providerStats.reduce((sum: number, provider: any) => sum + provider.totalRequests, 0); - const totalSuccessfulRequests = providerStats.reduce((sum: number, provider: any) => sum + provider.successfulRequests, 0); - const totalFailedRequests = providerStats.reduce((sum: number, provider: any) => sum + provider.failedRequests, 0); - + const totalApiRequests = providerStats.reduce( + (sum: number, provider: any) => sum + provider.totalRequests, + 0, + ); + const totalSuccessfulRequests = providerStats.reduce( + (sum: number, provider: any) => sum + provider.successfulRequests, + 0, + ); + const totalFailedRequests = providerStats.reduce( + (sum: number, provider: any) => sum + provider.failedRequests, + 0, + ); + // Get unique currencies that had activity const activeCurrencies = await prisma.priceHistory.findMany({ where: { @@ -77,9 +84,9 @@ router.get("/volume", async (req, res) => { select: { currency: true, }, - distinct: ['currency'], + distinct: ["currency"], }); - + // Get unique data sources for the day const activeSources = await prisma.priceHistory.findMany({ where: { @@ -91,11 +98,11 @@ router.get("/volume", async (req, res) => { select: { source: true, }, - distinct: ['source'], + distinct: ["source"], }); - + const volumeStats = { - date: targetDate.toISOString().split('T')[0], + date: targetDate.toISOString().split("T")[0], dataPoints: { priceHistoryEntries: priceHistoryCount, onChainConfirmations: onChainPriceCount, @@ -105,7 +112,11 @@ router.get("/volume", async (req, res) => { total: totalApiRequests, successful: totalSuccessfulRequests, failed: totalFailedRequests, - successRate: totalApiRequests > 0 ? (totalSuccessfulRequests / totalApiRequests * 100).toFixed(2) + '%' : '0%', + successRate: + totalApiRequests > 0 + ? ((totalSuccessfulRequests / totalApiRequests) * 100).toFixed(2) + + "%" + : "0%", }, activity: { activeCurrencies: activeCurrencies.length, @@ -116,13 +127,17 @@ router.get("/volume", async (req, res) => { providers: providerStats.map((provider: any) => ({ name: provider.providerName, totalRequests: provider.totalRequests, - successRate: provider.totalRequests > 0 - ? (provider.successfulRequests / provider.totalRequests * 100).toFixed(2) + '%' - : '0%', + successRate: + provider.totalRequests > 0 + ? ( + (provider.successfulRequests / provider.totalRequests) * + 100 + ).toFixed(2) + "%" + : "0%", lastActivity: provider.lastSuccess || provider.lastFailure, })), }; - + res.json({ success: true, data: volumeStats, diff --git a/src/services/hourlyAverageService.ts b/src/services/hourlyAverageService.ts index 8fae5733..a27a4534 100644 --- a/src/services/hourlyAverageService.ts +++ b/src/services/hourlyAverageService.ts @@ -10,7 +10,8 @@ export class HourlyAverageService { private checkIntervalMs: number; private timer: ReturnType | null = null; - constructor(checkIntervalMs: number = 15 * 60 * 1000) { // Every 15 minutes + constructor(checkIntervalMs: number = 15 * 60 * 1000) { + // Every 15 minutes this.checkIntervalMs = checkIntervalMs; } @@ -24,11 +25,13 @@ export class HourlyAverageService { } this.isRunning = true; - console.info(`[HourlyAverageService] Started with ${this.checkIntervalMs}ms check interval`); + console.info( + `[HourlyAverageService] Started with ${this.checkIntervalMs}ms check interval`, + ); // Run immediately on start - await this.processMissingHourlyStats().catch(err => { - console.error("[HourlyAverageService] Initial processing error:", err); + await this.processMissingHourlyStats().catch((err) => { + console.error("[HourlyAverageService] Initial processing error:", err); }); // Start periodic checks @@ -59,11 +62,16 @@ export class HourlyAverageService { try { const now = new Date(); // Start of the current hour - const currentHourStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours()); + const currentHourStart = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + now.getHours(), + ); // Get all active currencies const activeCurrencies = await prisma.currency.findMany({ - where: { isActive: true } + where: { isActive: true }, }); if (activeCurrencies.length === 0) { @@ -72,7 +80,9 @@ export class HourlyAverageService { // We look back at the last 24 hours for (let i = 1; i <= 24; i++) { - const targetHour = new Date(currentHourStart.getTime() - i * 60 * 60 * 1000); + const targetHour = new Date( + currentHourStart.getTime() - i * 60 * 60 * 1000, + ); const nextHour = new Date(targetHour.getTime() + 60 * 60 * 1000); for (const currency of activeCurrencies) { @@ -115,13 +125,16 @@ export class HourlyAverageService { }); console.info( - `[HourlyAverageService] ✅ Calculated average for ${currency.code} at ${targetHour.toISOString()}: ${aggregate._avg.rate}` + `[HourlyAverageService] ✅ Calculated average for ${currency.code} at ${targetHour.toISOString()}: ${aggregate._avg.rate}`, ); } } } } catch (error) { - console.error("[HourlyAverageService] Error processing hourly stats:", error); + console.error( + "[HourlyAverageService] Error processing hourly stats:", + error, + ); } } diff --git a/src/services/intelligenceService.ts b/src/services/intelligenceService.ts index 1d8b352c..6add072d 100644 --- a/src/services/intelligenceService.ts +++ b/src/services/intelligenceService.ts @@ -4,7 +4,7 @@ export class IntelligenceService { /** * Calculates the 24-hour price change for a given currency. * Compares the latest rate with the rate from approximately 24 hours ago. - * + * * @param currency - The currency code (e.g., "NGN", "GHS") * @returns A formatted string like "+2.5%" or "-1.2%" */ @@ -38,10 +38,12 @@ export class IntelligenceService { // If no record exists before 24h ago, try to find the earliest record available // but only if it's at least some reasonable time ago (e.g. 1h) - const baseRecord = historicalRecord || await prisma.priceHistory.findFirst({ - where: { currency: asset }, - orderBy: { timestamp: "asc" }, - }); + const baseRecord = + historicalRecord || + (await prisma.priceHistory.findFirst({ + where: { currency: asset }, + orderBy: { timestamp: "asc" }, + })); if (!baseRecord || baseRecord.id === latestRecord.id) { return "0.0%"; @@ -56,7 +58,7 @@ export class IntelligenceService { const changePercent = ((currentPrice - pastPrice) / pastPrice) * 100; const sign = changePercent >= 0 ? "+" : ""; - + return `${sign}${changePercent.toFixed(1)}%`; } catch (error) { console.error(`Error calculating 24h change for ${asset}:`, error); @@ -66,7 +68,7 @@ export class IntelligenceService { /** * Identifies currencies that haven't been updated in the database for over 30 minutes. - * + * * @returns A list of currency codes that are "Out of Date" */ async getStaleCurrencies(): Promise { @@ -91,7 +93,7 @@ export class IntelligenceService { const latest = c.priceHistory[0]; const hasNoHistory = !latest; const isOld = latest && new Date(latest.updatedAt) < staleTime; - + if (hasNoHistory || isOld) { staleCurrencies.push(c.code); } diff --git a/src/services/marketRate/coingeckoFetcher.ts b/src/services/marketRate/coingeckoFetcher.ts index aa1ba1a3..e69de29b 100644 --- a/src/services/marketRate/coingeckoFetcher.ts +++ b/src/services/marketRate/coingeckoFetcher.ts @@ -1,53 +0,0 @@ -import axios from "axios"; -import { OUTGOING_HTTP_TIMEOUT_MS } from "../../utils/httpTimeout.js"; -import { withRetry } from "../../utils/retryUtil.js"; -import { createFetcherLogger } from "../../utils/logger.js"; - -export class CoinGeckoFetcher { - private static readonly API_URL = "https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd"; - private static logger = createFetcherLogger("CoinGecko"); - - /** - * Fetches the current XLM/USD price from CoinGecko. - * @returns The price as a number (e.g., 0.12 for 1 XLM = $0.12) - * @throws Error if the fetch fails or the response is invalid - */ - static async fetchXlmUsdPrice(): Promise { - const response = await withRetry( - () => - axios.get(CoinGeckoFetcher.API_URL, { - timeout: OUTGOING_HTTP_TIMEOUT_MS, - }), - { - maxRetries: 3, - retryDelay: 1000, - onRetry: (attempt, error, delay) => { - CoinGeckoFetcher.logger.debug( - `API retry attempt ${attempt}/3 after ${delay}ms`, - { error: error.message, attempt, delay } - ); - }, - } - ); - - if ( - response.data && - response.data.stellar && - typeof response.data.stellar.usd === "number" - ) { - CoinGeckoFetcher.logger.info( - `Successfully fetched XLM/USD price`, - { price: response.data.stellar.usd } - ); - return response.data.stellar.usd; - } - - const error = new Error("Invalid response from CoinGecko API"); - CoinGeckoFetcher.logger.fetcherError( - error, - "API response validation failed", - { responseData: response.data } - ); - throw error; - } -} diff --git a/src/services/marketRate/ghsFetcher.ts b/src/services/marketRate/ghsFetcher.ts index e656935f..e69de29b 100644 --- a/src/services/marketRate/ghsFetcher.ts +++ b/src/services/marketRate/ghsFetcher.ts @@ -1,191 +0,0 @@ -import axios from 'axios'; -import { MarketRateFetcher, MarketRate } from './types'; -import { validatePrice } from './validation'; -import { OUTGOING_HTTP_TIMEOUT_MS } from '../../utils/httpTimeout.js'; - -type CoinGeckoPriceResponse = { - stellar?: { - ghs?: number; - usd?: number; - last_updated_at?: number; - }; -}; - -type ExchangeRateApiResponse = { - result?: string; - rates?: { - GHS?: number; - }; - time_last_update_unix?: number; -}; - -export class GHSRateFetcher implements MarketRateFetcher { - private readonly coinGeckoUrl = - "https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=ghs,usd&include_last_updated_at=true"; - - private readonly usdToGhsUrl = "https://open.er-api.com/v6/latest/USD"; - - getCurrency(): string { - return "GHS"; - } - - async fetchRate(): Promise { - const prices: { - rate: number; - timestamp: Date; - source: string; - trustLevel: SourceTrustLevel; - }[] = []; - - // Strategy 1: Try CoinGecko direct GHS price - try { - const coinGeckoResponse = await withRetry( - () => axios.get( - this.coinGeckoUrl, - { - timeout: OUTGOING_HTTP_TIMEOUT_MS, - headers: { - "User-Agent": "StellarFlow-Oracle/1.0", - }, - }, - ), - { maxRetries: 3, retryDelay: 1000 } - ); - - const stellarPrice = coinGeckoResponse.data.stellar; - if ( - stellarPrice && - typeof stellarPrice.ghs === "number" && - stellarPrice.ghs > 0 - ) { - const lastUpdatedAt = stellarPrice.last_updated_at - ? new Date(stellarPrice.last_updated_at * 1000) - : new Date(); - - prices.push({ - rate: stellarPrice.ghs, - timestamp: lastUpdatedAt, - source: "CoinGecko (direct)", - trustLevel: "standard", - }); - - // Success - reset error tracker - errorTracker.trackSuccess("GHS-price-fetch"); - } - } catch (error) { - console.debug("CoinGecko direct GHS price failed"); - } - - // Strategy 2: CoinGecko XLM/USD + ExchangeRate API - try { - const coinGeckoResponse = await withRetry( - () => axios.get( - this.coinGeckoUrl, - { - timeout: OUTGOING_HTTP_TIMEOUT_MS, - headers: { - "User-Agent": "StellarFlow-Oracle/1.0", - }, - }, - ), - { maxRetries: 3, retryDelay: 1000 } - ); - - const stellarPrice = coinGeckoResponse.data.stellar; - if ( - stellarPrice && - typeof stellarPrice.usd === "number" && - stellarPrice.usd > 0 - ) { - const exchangeRateResponse = await withRetry( - () => axios.get( - this.usdToGhsUrl, - { - timeout: OUTGOING_HTTP_TIMEOUT_MS, - headers: { - "User-Agent": "StellarFlow-Oracle/1.0", - }, - }, - ), - { maxRetries: 3, retryDelay: 1000 } - ); - - if (typeof stellarPrice.usd !== 'number') { - throw new Error('CoinGecko did not return a usable USD price for Stellar'); - } - } catch (error) { - console.debug("CoinGecko + ExchangeRate API failed"); - } - - const usdPrice = validatePrice(stellarPrice.usd); - - const exchangeRateResponse = await axios.get(this.usdToGhsUrl, { - timeout: OUTGOING_HTTP_TIMEOUT_MS, - headers: { - "User-Agent": "StellarFlow-Oracle/1.0", - }, - }); - - const usdToGhsRate = exchangeRateResponse.data.rates?.GHS; - if (exchangeRateResponse.data.result !== 'success' || typeof usdToGhsRate !== 'number') { - throw new Error('USD to GHS conversion feed did not return a usable GHS rate'); - } - } catch (error) { - console.debug("Alternative XLM pricing source failed"); - } - - // If we have prices, calculate median - if (prices.length > 0) { - let rateValues = prices.map((p) => p.rate).filter(p => p > 0); - rateValues = filterOutliers(rateValues); - const medianRate = calculateMedian(rateValues); - const mostRecentTimestamp = prices.reduce( - (latest, p) => (p.timestamp > latest ? p.timestamp : latest), - prices[0]?.timestamp ?? new Date(), - ); - - const validatedUsdToGhsRate = validatePrice(usdToGhsRate); - - const fxTimestamp = exchangeRateResponse.data.time_last_update_unix - ? new Date(exchangeRateResponse.data.time_last_update_unix * 1000) - : lastUpdatedAt; - - return { - currency: 'GHS', - rate: validatePrice(usdPrice * validatedUsdToGhsRate), - timestamp: fxTimestamp > lastUpdatedAt ? fxTimestamp : lastUpdatedAt, - source: 'CoinGecko + ExchangeRate API' - }; - } - - // All strategies failed - track failure and send notification if 3 consecutive failures - const error = new Error("All GHS rate sources failed"); - const thresholdReached = errorTracker.trackFailure("GHS-price-fetch", { - errorMessage: error.message, - timestamp: new Date(), - service: "GHSRateFetcher", - }); - - if (thresholdReached) { - await webhookService.sendErrorNotification({ - errorType: "PRICE_FETCH_FAILED_CONSECUTIVE", - errorMessage: error.message, - attempts: 3, - service: "GHSRateFetcher", - pricePair: "XLM/GHS", - timestamp: new Date(), - }); - } - - throw error; - } - - async isHealthy(): Promise { - try { - const rate = await this.fetchRate(); - return rate.rate > 0; - } catch { - return false; - } - } -} diff --git a/src/services/marketRate/index.ts b/src/services/marketRate/index.ts index b2056f12..8b663e93 100644 --- a/src/services/marketRate/index.ts +++ b/src/services/marketRate/index.ts @@ -1,5 +1,5 @@ -export * from './types'; -export * from './kesFetcher'; -export * from './ghsFetcher'; -export * from './ngnFetcher'; -export * from './marketRateService'; +export * from "./types"; +export * from "./kesFetcher"; +export * from "./ghsFetcher"; +export * from "./ngnFetcher"; +export * from "./marketRateService"; diff --git a/src/services/marketRate/kesFetcher.ts b/src/services/marketRate/kesFetcher.ts index 7a91126f..e69de29b 100644 --- a/src/services/marketRate/kesFetcher.ts +++ b/src/services/marketRate/kesFetcher.ts @@ -1,668 +0,0 @@ -import axios from 'axios'; -import { MarketRateFetcher, MarketRate, RateSource } from './types'; -import { validatePrice } from './validation'; -import { OUTGOING_HTTP_TIMEOUT_MS } from '../../utils/httpTimeout.js'; - -/** - * Binance Ticker Response Interface - */ -interface BinanceTickerResponse { - symbol: string; - lastPrice: string; - priceChange: string; - priceChangePercent: string; - volume: string; - [key: string]: unknown; -} - -/** - * Binance P2P Response Interface - */ -interface BinanceP2PResponse { - data?: Array<{ - adv?: { - price: string; - asset: string; - fiatUnit: string; - }; - orderNumber?: string; - [key: string]: unknown; - }>; - success?: boolean; - message?: string; -} - -/** - * Binance Unified Trading (Spot) Ticker Response - */ -interface BinanceTicker24hResponse { - symbol: string; - lastPrice: string; - priceChange: string; - priceChangePercent: string; - weightedAvgPrice: string; - [key: string]: unknown; -} - -/** - * Circuit Breaker States - */ -enum CircuitState { - CLOSED = "CLOSED", // Normal operation, requests pass through - OPEN = "OPEN", // Failing, reject requests immediately - HALF_OPEN = "HALF_OPEN", // Testing if service recovered -} - -/** - * Circuit Breaker Configuration - */ -interface CircuitBreakerConfig { - failureThreshold: number; - recoveryTimeoutMs: number; - halfOpenMaxAttempts: number; -} - -/** - * Circuit Breaker Implementation - */ -class CircuitBreaker { - private state: CircuitState = CircuitState.CLOSED; - private failureCount = 0; - private lastFailureTime: Date | null = null; - private halfOpenAttempts = 0; - - constructor(private readonly config: CircuitBreakerConfig) {} - - async execute(operation: () => Promise): Promise { - if (this.state === CircuitState.OPEN) { - if (this.shouldAttemptRecovery()) { - this.state = CircuitState.HALF_OPEN; - this.halfOpenAttempts = 0; - } else { - throw new Error( - "Circuit breaker is OPEN - service temporarily unavailable", - ); - } - } - - if (this.state === CircuitState.HALF_OPEN) { - this.halfOpenAttempts++; - if (this.halfOpenAttempts > this.config.halfOpenMaxAttempts) { - throw new Error("Circuit breaker half-open test limit exceeded"); - } - } - - try { - const result = await operation(); - this.onSuccess(); - return result; - } catch (error) { - this.onFailure(); - throw error; - } - } - - private onSuccess(): void { - this.failureCount = 0; - if (this.state === CircuitState.HALF_OPEN) { - this.state = CircuitState.CLOSED; - } - } - - private onFailure(): void { - this.failureCount++; - this.lastFailureTime = new Date(); - - if (this.state === CircuitState.HALF_OPEN) { - this.state = CircuitState.OPEN; - } else if (this.failureCount >= this.config.failureThreshold) { - this.state = CircuitState.OPEN; - } - } - - private shouldAttemptRecovery(): boolean { - if (!this.lastFailureTime) return true; - const elapsed = Date.now() - this.lastFailureTime.getTime(); - return elapsed >= this.config.recoveryTimeoutMs; - } - - getState(): CircuitState { - return this.state; - } - - reset(): void { - this.state = CircuitState.CLOSED; - this.failureCount = 0; - this.lastFailureTime = null; - this.halfOpenAttempts = 0; - } -} - -/** - * Rate Source Configuration - */ -const RATE_SOURCES: RateSource[] = [ - { - name: "Binance Spot API", - url: "https://api.binance.com/api/v3/ticker/price", - }, - { - name: "Binance Unified Trading (24h)", - url: "https://api.binance.com/api/v3/ticker/24hr", - }, - { - name: "Central Bank of Kenya", - url: "https://www.centralbank.go.ke/wp-json/fx-rate/v1/rates", - }, - { - name: "XE.com", - url: "https://www.xe.com/currencytables/?from=USD&to=KES", - }, -]; - -/** - * API Configuration - */ -const BINANCE_SPOT_URL = "https://api.binance.com/api/v3/ticker/price"; -const BINANCE_24H_URL = "https://api.binance.com/api/v3/ticker/24hr"; -const BINANCE_P2P_URL = - "https://p2p-api.binance.com/bapi/c2c/v2/public/c2c/adv/search"; - -/** - * Default timeout for API requests (ms) - */ -const DEFAULT_TIMEOUT_MS = OUTGOING_HTTP_TIMEOUT_MS; - -/** - * Approximate KES/USD rate for calculation fallback - * Note: In production, this should be fetched from a reliable source - */ -const APPROXIMATE_KES_USD_RATE = 130.5; - -/** - * KES/XLM Rate Fetcher using Binance Public API - * Implements multiple strategies to fetch KES rates: - * 1. Direct Binance Spot API (XLMKES pair) - * 2. Binance P2P API for KES - * 3. Binance Spot API (XLMUSDT) × USD/KES calculation - * 4. Fallback to Central Bank of Kenya - */ -export class KESRateFetcher implements MarketRateFetcher { - private readonly circuitBreaker: CircuitBreaker; - - constructor() { - this.circuitBreaker = new CircuitBreaker({ - failureThreshold: 5, - recoveryTimeoutMs: 30000, - halfOpenMaxAttempts: 3, - }); - } - - /** - * Get the currency code this fetcher handles - */ - getCurrency(): string { - return "KES"; - } - - /** - * Fetch the KES/XLM rate with comprehensive error handling - * Tries multiple strategies in order of reliability - */ - async fetchRate(): Promise { - const errors: RateFetchError[] = []; - - // Strategy 1: Try Binance API (with circuit breaker and retry) - try { - const binanceRate = await this.circuitBreaker.execute(() => - withRetry( - () => this.fetchFromBinance(), - { - maxRetries: 3, - retryDelay: 1000, - onRetry: (attempt, error, delay) => { - console.debug( - `Binance API retry attempt ${attempt}/3 after ${delay}ms. Error: ${error.message}` - ); - }, - } - ), - ); - - if (binanceRate) { - console.info(`✅ KES rate fetched from Binance: ${binanceRate.rate}`); - return binanceRate; - } - } catch (error) { - const errorMsg = - error instanceof Error ? error.message : "Unknown Binance error"; - console.warn(`⚠️ Binance API failed: ${errorMsg}`); - errors.push({ - source: "Binance API", - message: errorMsg, - timestamp: new Date(), - }); - } - - // Strategy 2: Try Central Bank of Kenya - try { - const cbkRate = await this.fetchFromCBK(); - if (cbkRate) { - console.info(`✅ KES rate fetched from CBK: ${cbkRate.rate}`); - return cbkRate; - } - } catch (error) { - const errorMsg = - error instanceof Error ? error.message : "Unknown CBK error"; - console.warn(`⚠️ Central Bank of Kenya API failed: ${errorMsg}`); - errors.push({ - source: "Central Bank of Kenya", - message: errorMsg, - timestamp: new Date(), - }); - } - - // Strategy 3: Try alternative sources - for (const source of RATE_SOURCES.slice(2)) { - try { - const rate = await withRetry( - () => this.fetchFromSource(source), - { - maxRetries: 3, - retryDelay: 1000, - onRetry: (attempt, error, delay) => { - console.debug( - `${source.name} retry attempt ${attempt}/3 after ${delay}ms. Error: ${error.message}` - ); - }, - } - ); - if (rate) { - console.info(`✅ KES rate fetched from ${source.name}: ${rate.rate}`); - return rate; - } - } catch (error) { - const errorMsg = - error instanceof Error - ? error.message - : `Unknown ${source.name} error`; - console.warn(`⚠️ ${source.name} failed: ${errorMsg}`); - errors.push({ - source: source.name, - message: errorMsg, - timestamp: new Date(), - }); - } - } - - // All sources failed - throw comprehensive error - const errorMessage = this.buildErrorMessage(errors); - console.error(`❌ All KES rate sources failed: ${errorMessage}`); - throw new Error(errorMessage); - } - - /** - * Fetch KES/XLM rate from Binance API - * Tries multiple strategies: - * 1. Direct XLMKES pair - * 2. Binance P2P API - * 3. XLMUSDT × KES/USD calculation - * Returns all successful rates to calculate median - */ - private async fetchFromBinance(): Promise { - const prices: { - rate: number; - timestamp: Date; - source: string; - trustLevel: SourceTrustLevel; - }[] = []; - - // Strategy 1: Direct XLMKES pair - try { - const directRate = await this.fetchBinanceSpotPrice("XLMKES"); - if (directRate) { - prices.push({ - rate: directRate.rate, - timestamp: directRate.timestamp, - source: "Binance Spot (XLMKES)", - trustLevel: "standard", - }); - } - } catch (error) { - console.debug("Direct XLMKES pair not available"); - } - - // Strategy 2: Try Binance P2P API - try { - const p2pRate = await this.fetchBinanceP2PRate(); - if (p2pRate) { - prices.push({ - rate: p2pRate.rate, - timestamp: p2pRate.timestamp, - source: p2pRate.source, - trustLevel: "new", - }); - } - } catch (error) { - console.debug("Binance P2P API not available"); - } - - // Strategy 3: XLMUSDT × KES/USD calculation - try { - const xlmUsdRate = await this.fetchBinanceSpotPrice("XLMUSDT"); - if (xlmUsdRate) { - prices.push({ - rate: xlmUsdRate.rate * APPROXIMATE_KES_USD_RATE, - timestamp: xlmUsdRate.timestamp, - source: "Binance Spot (XLMUSDT × KES/USD)", - trustLevel: "new", - }); - } - } catch (error) { - console.debug("XLMUSDT pair not available"); - } - - // If no prices were collected, return null - if (prices.length === 0) { - return null; - } - - // Calculate median rate from all sources (with outlier filtering) - let rateValues = prices.map((p) => p.rate).filter(p => p > 0); - rateValues = filterOutliers(rateValues); - const medianRate = calculateMedian(rateValues); - - // Return the median with the most recent timestamp - const firstTimestamp = prices[0]?.timestamp ?? new Date(); - const mostRecentTimestamp = prices.reduce( - (latest, p) => (p.timestamp > latest ? p.timestamp : latest), - firstTimestamp, - ); - - const weightedInput = prices.map((p) => ({ - value: p.rate, - trustLevel: p.trustLevel as SourceTrustLevel, - })); - const weightedRate = calculateWeightedAverage(weightedInput); - - return { - currency: "KES", - rate: weightedRate, - timestamp: mostRecentTimestamp, - source: `Binance (Weighted average of ${prices.length} sources, outliers filtered)`, - }; - } - - /** - * Fetch a specific trading pair price from Binance Spot API - */ - private async fetchBinanceSpotPrice( - symbol: string, - ): Promise<{ rate: number; timestamp: Date } | null> { - try { - const response = await withRetry( - () => axios.get( - BINANCE_SPOT_URL, - { - params: { symbol }, - timeout: DEFAULT_TIMEOUT_MS, - headers: { - "User-Agent": "StellarFlow-Oracle/1.0", - Accept: "application/json", - }, - }, - ), - { - maxRetries: 3, - retryDelay: 1000, - } - ); - - if (response.data && response.data.lastPrice) { - const rate = parseFloat(response.data.lastPrice); - if (!isNaN(rate) && rate > 0) { - return { - rate, - timestamp: new Date(), - }; - } - } - - return null; - } catch (error) { - this.handleApiError(error, `Binance Spot (${symbol})`); - return null; - } - } - - /** - * Fetch KES rates from Binance P2P API - * Note: Binance P2P API may require authentication or have CORS restrictions - */ - private async fetchBinanceP2PRate(): Promise { - try { - const response = await withRetry( - () => axios.post( - BINANCE_P2P_URL, - { - fiat: "KES", - asset: "XLM", - merchantCheck: false, - rows: 5, - page: 1, - tradeType: "BUY", - }, - { - timeout: DEFAULT_TIMEOUT_MS, - headers: { - "User-Agent": "StellarFlow-Oracle/1.0", - "Content-Type": "application/json", - Accept: "application/json", - }, - }, - ), - { - maxRetries: 3, - retryDelay: 1000, - } - ); - - if (response.data?.data && response.data.data.length > 0) { - // Calculate average price from available offers - const prices = response.data.data - .map((item) => item.adv?.price) - .filter((price): price is string => !!price) - .map((price) => parseFloat(price)) - .filter((price) => !isNaN(price) && price > 0); - - if (prices.length > 0) { - const avgPrice = prices.reduce((a, b) => a + b, 0) / prices.length; - return { - currency: "KES", - rate: avgPrice, - timestamp: new Date(), - source: "Binance P2P API", - }; - } - } - - return null; - } catch (error) { - this.handleApiError(error, "Binance P2P API"); - return null; - } - } - - /** - * Fetch KES/USD rate from Central Bank of Kenya - */ - private async fetchFromCBK(): Promise { - const cbkSource = RATE_SOURCES[2]; - if (!cbkSource) { - console.warn("Central Bank of Kenya source not configured"); - return null; - } - - try { - const response = await withRetry( - () => axios.get(cbkSource.url, { - timeout: OUTGOING_HTTP_TIMEOUT_MS, - headers: { - "User-Agent": "StellarFlow-Oracle/1.0", - Accept: "application/json", - }, - }), - { - maxRetries: 3, - retryDelay: 1000, - } - ); - - // CBK API returns rates in KES per USD - const rates = response.data; - if (rates && rates.length > 0) { - const latestRate = rates[0]; - return { - currency: 'KES', - rate: validatePrice(Number(latestRate.rate)), - timestamp: new Date(latestRate.date), - source: cbkSource.name, - }; - } - - return null; - } catch (error) { - this.handleApiError(error, cbkSource.name); - return null; - } - } - - /** - * Fetch rate from alternative sources - */ - private async fetchFromSource( - source: RateSource, - ): Promise { - try { - const response = await withRetry( - () => axios.get(source.url, { - timeout: OUTGOING_HTTP_TIMEOUT_MS, - headers: { - "User-Agent": "StellarFlow-Oracle/1.0", - Accept: "application/json", - }, - }), - { - maxRetries: 3, - retryDelay: 1000, - } - ); - - // Placeholder rate - in reality, you'd parse the actual response - const placeholderRate = validatePrice(130.5); // Approximate KES/USD rate - - return { - currency: "KES", - rate: APPROXIMATE_KES_USD_RATE, - timestamp: new Date(), - source: source.name, - }; - } catch (error) { - this.handleApiError(error, source.name); - return null; - } - } - - /** - * Handle API errors with detailed logging - */ - private handleApiError(error: unknown, source: string): void { - if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError; - - if (axiosError.response) { - // Server responded with error status - console.warn( - `${source} returned status ${axiosError.response.status}: ` + - `${axiosError.response.statusText}`, - ); - } else if ( - axiosError.code === "ECONNABORTED" || - axiosError.code === "ETIMEDOUT" - ) { - // Request timeout - console.warn(`${source} request timed out`); - } else if (axiosError.code === "ERR_NETWORK") { - // Network error - console.warn(`${source} network error - service may be down`); - } else if (axiosError.message.includes("Network Error")) { - // CORS or network issue - console.warn( - `${source} network error - check connectivity or CORS settings`, - ); - } else { - console.warn(`${source} error: ${axiosError.message}`); - } - } else { - console.warn(`${source} unexpected error:`, error); - } - } - - /** - * Build comprehensive error message from all failures - */ - private buildErrorMessage(errors: RateFetchError[]): string { - if (errors.length === 0) { - return "Failed to fetch KES rate: All sources returned no data"; - } - - const messages = errors.map((e) => `${e.source}: ${e.message}`).join("; "); - return `Failed to fetch KES rate from all sources. Errors: ${messages}`; - } - - /** - * Health check for the fetcher - * Tests Binance API availability specifically - */ - async isHealthy(): Promise { - try { - const testRate = await withRetry( - () => this.fetchFromBinance(), - { - maxRetries: 1, - retryDelay: 1000, - } - ); - - const healthy = testRate !== null && testRate.rate > 0; - console.debug( - `Health check result: ${healthy ? "HEALTHY" : "UNHEALTHY"}`, - ); - return healthy; - } catch (error) { - console.warn( - "Health check failed:", - error instanceof Error ? error.message : "Unknown error", - ); - return false; - } - } - - /** - * Get circuit breaker status for diagnostics - */ - getCircuitBreakerStatus(): { state: CircuitState; failureCount: number } { - return { - state: this.circuitBreaker.getState(), - failureCount: 0, // Internal state not exposed - }; - } - - /** - * Reset circuit breaker (for manual intervention) - */ - resetCircuitBreaker(): void { - this.circuitBreaker.reset(); - console.info("Circuit breaker reset"); - } -} diff --git a/src/services/marketRate/marketRateService.ts b/src/services/marketRate/marketRateService.ts index 67010457..b6b9e831 100644 --- a/src/services/marketRate/marketRateService.ts +++ b/src/services/marketRate/marketRateService.ts @@ -30,6 +30,13 @@ export class MarketRateService { private readonly LATEST_PRICES_REDIS_TTL_SECONDS = 5; private multiSigEnabled: boolean; private remoteOracleServers: string[] = []; + private pendingSubmissions: Array<{ + currency: string; + rate: number; + reviewId: number; + }> = []; + private batchTimeout: any = null; + private readonly BATCH_WINDOW_MS = 5000; // 5 seconds bundle window constructor() { this.stellarService = new StellarService(); @@ -158,9 +165,9 @@ export class MarketRateService { if (!reviewAssessment.manualReviewRequired) { try { - const memoId = this.stellarService.generateMemoId(normalizedCurrency); - if (this.multiSigEnabled) { + const memoId = + this.stellarService.generateMemoId(normalizedCurrency); // Multi-sig workflow: create request and collect signatures console.info( `[MarketRateService] Starting multi-sig workflow for ${normalizedCurrency} rate ${rate.rate}`, @@ -222,6 +229,20 @@ export class MarketRateService { console.info( `[MarketRateService] Single-sig price update submitted for ${normalizedCurrency}`, ); + + this.pendingSubmissions.push({ + currency: normalizedCurrency, + rate: rate.rate, + reviewId: reviewAssessment.reviewRecordId, + }); + + // Start batch timeout if not already running + if (!this.batchTimeout) { + this.batchTimeout = setTimeout( + () => this.flushBatchSubmissions(), + this.BATCH_WINDOW_MS, + ); + } } } catch (stellarError) { console.error( diff --git a/src/services/marketRate/ngnFetcher.ts b/src/services/marketRate/ngnFetcher.ts index ca2a15db..43db720a 100644 --- a/src/services/marketRate/ngnFetcher.ts +++ b/src/services/marketRate/ngnFetcher.ts @@ -76,10 +76,9 @@ export class NGNRateFetcher implements MarketRateFetcher { private logger = createFetcherLogger("NGNRate"); private vtpassBase(): string { - return (process.env.VTPASS_API_BASE_URL ?? "https://vtpass.com/api").replace( - /\/$/, - "", - ); + return ( + process.env.VTPASS_API_BASE_URL ?? "https://vtpass.com/api" + ).replace(/\/$/, ""); } private vtpassHeaders(): Record | undefined { @@ -163,7 +162,8 @@ export class NGNRateFetcher implements MarketRateFetcher { const lastUpdatedAt = coinGeckoResponse.data.stellar?.last_updated_at ? new Date(coinGeckoResponse.data.stellar.last_updated_at * 1000) : new Date(); - const ts = vt.timestamp > lastUpdatedAt ? vt.timestamp : lastUpdatedAt; + const ts = + vt.timestamp > lastUpdatedAt ? vt.timestamp : lastUpdatedAt; prices.push({ rate: usd * vt.ngnPerUsd, @@ -254,7 +254,8 @@ export class NGNRateFetcher implements MarketRateFetcher { prices.push({ rate: stellarPrice.usd * usdToNgn, - timestamp: fxTimestamp > lastUpdatedAt ? fxTimestamp : lastUpdatedAt, + timestamp: + fxTimestamp > lastUpdatedAt ? fxTimestamp : lastUpdatedAt, source: "CoinGecko + ExchangeRate API (USD->NGN)", providerKey: "coinGeckoExchangeRateUsdNgn", }); diff --git a/src/services/multiSigService.ts b/src/services/multiSigService.ts index dfd07443..e69de29b 100644 --- a/src/services/multiSigService.ts +++ b/src/services/multiSigService.ts @@ -1,411 +0,0 @@ -import prisma from "../lib/prisma"; -import { Keypair } from "@stellar/stellar-sdk"; -import dotenv from "dotenv"; -import { createTimeoutSignal } from "../utils/httpTimeout.js"; - -dotenv.config(); - -export interface SignatureRequest { - multiSigPriceId: number; - currency: string; - rate: number; - source: string; - memoId: string; - requiredSignatures: number; -} - -export interface SignaturePayload { - multiSigPriceId: number; - currency: string; - rate: number; - source: string; - memoId: string; - signerPublicKey: string; -} - -export class MultiSigService { - private localSignerPublicKey: string; - private localSignerSecret: string; - private signerName: string; - private readonly SIGNATURE_EXPIRY_MS = 3600000; // 1 hour - private readonly REQUIRED_SIGNATURES = 2; // Default: 2 signatures needed - - constructor() { - const secret = process.env.ORACLE_SECRET_KEY || process.env.SOROBAN_ADMIN_SECRET; - if (!secret) { - throw new Error( - "ORACLE_SECRET_KEY or SOROBAN_ADMIN_SECRET not found in environment variables" - ); - } - - this.localSignerSecret = secret; - this.localSignerPublicKey = Keypair.fromSecret(secret).publicKey(); - this.signerName = process.env.ORACLE_SIGNER_NAME || "oracle-server"; - - const requiredSigs = process.env.MULTI_SIG_REQUIRED_COUNT; - if (requiredSigs) { - const parsed = parseInt(requiredSigs, 10); - if (!isNaN(parsed) && parsed > 0) { - (this as any).REQUIRED_SIGNATURES = parsed; - } - } - } - - /** - * Create a multi-sig price update request. - * This initiates the process where the price needs to be signed by multiple servers. - */ - async createMultiSigRequest( - priceReviewId: number, - currency: string, - rate: number, - source: string, - memoId: string - ): Promise { - const expiresAt = new Date(Date.now() + this.SIGNATURE_EXPIRY_MS); - - const created = await prisma.multiSigPrice.create({ - data: { - priceReviewId, - currency, - rate, - source, - status: "PENDING", - requiredSignatures: this.REQUIRED_SIGNATURES, - collectedSignatures: 0, - expiresAt, - }, - }); - - console.info( - `[MultiSig] Created signature request ${created.id} for ${currency} rate ${rate}` - ); - - return { - multiSigPriceId: created.id, - currency, - rate, - source, - memoId, - requiredSignatures: this.REQUIRED_SIGNATURES, - }; - } - - /** - * Sign a multi-sig price update locally. - * This creates a signature from the current server instance and records it. - */ - async signMultiSigPrice( - multiSigPriceId: number - ): Promise<{ signature: string; signerPublicKey: string }> { - // Fetch the multi-sig price record - const multiSigPrice = await prisma.multiSigPrice.findUnique({ - where: { id: multiSigPriceId }, - }); - - if (!multiSigPrice) { - throw new Error(`MultiSigPrice ${multiSigPriceId} not found`); - } - - if (multiSigPrice.status !== "PENDING") { - throw new Error( - `Cannot sign MultiSigPrice ${multiSigPriceId} - status is ${multiSigPrice.status}` - ); - } - - if (new Date() > multiSigPrice.expiresAt) { - // Mark as expired - await prisma.multiSigPrice.update({ - where: { id: multiSigPriceId }, - data: { status: "EXPIRED" }, - }); - throw new Error(`MultiSigPrice ${multiSigPriceId} has expired`); - } - - // Create a deterministic signature message based on the price data - const signatureMessage = this.createSignatureMessage( - multiSigPrice.currency, - multiSigPrice.rate.toString(), - multiSigPrice.source - ); - - // Sign the message - const keypair = Keypair.fromSecret(this.localSignerSecret); - - // Convert message to buffer and sign - const messageBuffer = Buffer.from(signatureMessage, "utf-8"); - const signatureBuffer = keypair.sign(messageBuffer); - const signature = signatureBuffer.toString("hex"); - - // Record the signature - await prisma.multiSigSignature.create({ - data: { - multiSigPriceId, - signerPublicKey: this.localSignerPublicKey, - signerName: this.signerName, - signature, - }, - }); - - // Increment the collected signatures count - const updated = await prisma.multiSigPrice.update({ - where: { id: multiSigPriceId }, - data: { - collectedSignatures: { - increment: 1, - }, - }, - }); - - console.info( - `[MultiSig] Added signature ${updated.collectedSignatures}/${updated.requiredSignatures} for MultiSigPrice ${multiSigPriceId}` - ); - - // If we have all required signatures, mark as approved - if (updated.collectedSignatures >= updated.requiredSignatures) { - await this.approveMultiSigPrice(multiSigPriceId); - } - - return { signature, signerPublicKey: this.localSignerPublicKey }; - } - - /** - * Request a signature from a remote server. - * Sends an HTTP request to a peer server to sign the price update. - */ - async requestRemoteSignature( - multiSigPriceId: number, - remoteServerUrl: string - ): Promise<{ success: boolean; error?: string }> { - try { - const multiSigPrice = await prisma.multiSigPrice.findUnique({ - where: { id: multiSigPriceId }, - }); - - if (!multiSigPrice) { - return { - success: false, - error: `MultiSigPrice ${multiSigPriceId} not found`, - }; - } - - const payload: SignaturePayload = { - multiSigPriceId, - currency: multiSigPrice.currency, - rate: multiSigPrice.rate.toNumber(), - source: multiSigPrice.source, - memoId: multiSigPrice.memoId || "", - signerPublicKey: this.localSignerPublicKey, - }; - - // Make HTTP request to remote server - const response = await fetch(`${remoteServerUrl}/api/v1/price-updates/sign`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${process.env.MULTI_SIG_AUTH_TOKEN || ""}`, - }, - body: JSON.stringify(payload), - signal: createTimeoutSignal(), - }); - - if (!response.ok) { - const error = await response.text().catch(() => response.statusText); - return { success: false, error: `Remote server error: ${error}` }; - } - - const result = await response.json(); - - if (result.signature && result.signerPublicKey) { - // Record the remote signature - await prisma.multiSigSignature.create({ - data: { - multiSigPriceId, - signerPublicKey: result.signerPublicKey, - signerName: result.signerName || "remote-signer", - signature: result.signature, - }, - }).catch((err: any) => { - // Ignore duplicate signers - if (err.code !== "P2002") throw err; - }); - - // Increment collected signatures - const updated = await prisma.multiSigPrice.update({ - where: { id: multiSigPriceId }, - data: { - collectedSignatures: { - increment: 1, - }, - }, - }); - - console.info( - `[MultiSig] Added remote signature ${updated.collectedSignatures}/${updated.requiredSignatures} for MultiSigPrice ${multiSigPriceId}` - ); - - // Check if all signatures are collected - if (updated.collectedSignatures >= updated.requiredSignatures) { - await this.approveMultiSigPrice(multiSigPriceId); - } - } - - return { success: true }; - } catch (error) { - console.error( - `[MultiSig] Failed to request signature from ${remoteServerUrl}:`, - error - ); - return { success: false, error: String(error) }; - } - } - - /** - * Get a pending multi-sig price by ID. - * Returns the price details and current signature status. - */ - async getMultiSigPrice(multiSigPriceId: number): Promise { - const multiSigPrice = await prisma.multiSigPrice.findUnique({ - where: { id: multiSigPriceId }, - include: { - multiSigSignatures: { - select: { - signerPublicKey: true, - signerName: true, - signature: true, - signedAt: true, - }, - }, - }, - }); - - return multiSigPrice; - } - - /** - * Get all pending multi-sig prices. - * Useful for monitoring and checking expiration. - */ - async getPendingMultiSigPrices(): Promise { - return await prisma.multiSigPrice.findMany({ - where: { status: "PENDING" }, - include: { - multiSigSignatures: { - select: { - signerPublicKey: true, - signerName: true, - signedAt: true, - }, - }, - }, - orderBy: { requestedAt: "desc" }, - }); - } - - /** - * Clean up expired multi-sig prices. - * Should be called periodically by a background job. - */ - async cleanupExpiredRequests(): Promise { - const now = new Date(); - const result = await prisma.multiSigPrice.updateMany({ - where: { - status: "PENDING", - expiresAt: { lt: now }, - }, - data: { - status: "EXPIRED", - }, - }); - - if (result.count > 0) { - console.warn( - `[MultiSig] Expired ${result.count} multi-sig price requests` - ); - } - - return result.count; - } - - /** - * Mark a multi-sig price as approved (all signatures collected). - * This happens automatically when all required signatures are collected. - */ - private async approveMultiSigPrice(multiSigPriceId: number): Promise { - await prisma.multiSigPrice.update({ - where: { id: multiSigPriceId }, - data: { - status: "APPROVED", - }, - }); - - console.info( - `[MultiSig] MultiSigPrice ${multiSigPriceId} is now APPROVED (all signatures collected)` - ); - } - - /** - * Get all signatures for a multi-sig price. - * Returns the signatures needed for submitting to Stellar. - */ - async getSignatures(multiSigPriceId: number): Promise { - const signatures = await prisma.multiSigSignature.findMany({ - where: { multiSigPriceId }, - }); - - return signatures; - } - - /** - * Mark a multi-sig price as submitted to Stellar. - * Records the transaction hash and memo ID. - */ - async recordSubmission( - multiSigPriceId: number, - memoId: string, - stellarTxHash: string - ): Promise { - await prisma.multiSigPrice.update({ - where: { id: multiSigPriceId }, - data: { - memoId, - stellarTxHash, - submittedAt: new Date(), - }, - }); - - console.info( - `[MultiSig] MultiSigPrice ${multiSigPriceId} submitted to Stellar - TxHash: ${stellarTxHash}` - ); - } - - /** - * Create a deterministic message for signing. - * Must be consistent across all servers to ensure valid multi-sig. - */ - private createSignatureMessage( - currency: string, - rate: string, - source: string - ): string { - // Create a deterministic message format - // Format: "SF-PRICE---" - return `SF-PRICE-${currency}-${rate}-${source}`; - } - - /** - * Get this server's signer identity. - */ - getLocalSignerInfo(): { - publicKey: string; - name: string; - } { - return { - publicKey: this.localSignerPublicKey, - name: this.signerName, - }; - } -} - -// Export singleton instance -export const multiSigService = new MultiSigService(); diff --git a/src/services/multiSigSubmissionService.ts b/src/services/multiSigSubmissionService.ts index cb4d2676..94c1d8ff 100644 --- a/src/services/multiSigSubmissionService.ts +++ b/src/services/multiSigSubmissionService.ts @@ -28,15 +28,13 @@ export class MultiSigSubmissionService { */ async start(): Promise { if (this.isRunning) { - console.warn( - "[MultiSigSubmissionService] Service is already running" - ); + console.warn("[MultiSigSubmissionService] Service is already running"); return; } this.isRunning = true; console.info( - `[MultiSigSubmissionService] Started with ${this.pollIntervalMs}ms poll interval` + `[MultiSigSubmissionService] Started with ${this.pollIntervalMs}ms poll interval`, ); // Initial check @@ -45,10 +43,7 @@ export class MultiSigSubmissionService { // Start periodic polling this.pollTimer = setInterval(() => { this.checkAndSubmitApprovedPrices().catch((err) => { - console.error( - "[MultiSigSubmissionService] Polling error:", - err - ); + console.error("[MultiSigSubmissionService] Polling error:", err); }); }, this.pollIntervalMs); } @@ -92,7 +87,7 @@ export class MultiSigSubmissionService { } console.info( - `[MultiSigSubmissionService] Found ${approvedPrices.length} approved prices to submit` + `[MultiSigSubmissionService] Found ${approvedPrices.length} approved prices to submit`, ); // Process each approved price @@ -102,7 +97,7 @@ export class MultiSigSubmissionService { } catch (error) { console.error( `[MultiSigSubmissionService] Failed to submit multi-sig price ${multiSigPrice.id}:`, - error + error, ); // Continue with next price, don't let one failure block others } @@ -110,7 +105,7 @@ export class MultiSigSubmissionService { } catch (error) { console.error( "[MultiSigSubmissionService] Error checking approved prices:", - error + error, ); } } @@ -128,13 +123,13 @@ export class MultiSigSubmissionService { if (signatures.length === 0) { console.warn( - `[MultiSigSubmissionService] No signatures found for multi-sig price ${multiSigPrice.id}` + `[MultiSigSubmissionService] No signatures found for multi-sig price ${multiSigPrice.id}`, ); return; } console.info( - `[MultiSigSubmissionService] Submitting multi-sig price ${multiSigPrice.id} (${multiSigPrice.currency} @ ${multiSigPrice.rate}) with ${signatures.length} signatures` + `[MultiSigSubmissionService] Submitting multi-sig price ${multiSigPrice.id} (${multiSigPrice.currency} @ ${multiSigPrice.rate}) with ${signatures.length} signatures`, ); // Submit to Stellar with multiple signatures @@ -143,30 +138,26 @@ export class MultiSigSubmissionService { multiSigPrice.currency, multiSigPrice.rate.toNumber(), memoId, - signatures + signatures, ); // Record the submission - await multiSigService.recordSubmission( - multiSigPrice.id, - memoId, - txHash - ); + await multiSigService.recordSubmission(multiSigPrice.id, memoId, txHash); // Mark the associated price review as submitted await priceReviewService.markContractSubmitted( multiSigPrice.priceReviewId, memoId, - txHash + txHash, ); console.info( - `[MultiSigSubmissionService] ✅ Successfully submitted multi-sig price ${multiSigPrice.id} - TxHash: ${txHash}` + `[MultiSigSubmissionService] ✅ Successfully submitted multi-sig price ${multiSigPrice.id} - TxHash: ${txHash}`, ); } catch (error) { console.error( `[MultiSigSubmissionService] Error submitting multi-sig price ${multiSigPrice.id}:`, - error + error, ); throw error; } @@ -181,15 +172,12 @@ export class MultiSigSubmissionService { const count = await multiSigService.cleanupExpiredRequests(); if (count > 0) { console.info( - `[MultiSigSubmissionService] Cleaned up ${count} expired multi-sig requests` + `[MultiSigSubmissionService] Cleaned up ${count} expired multi-sig requests`, ); } return count; } catch (error) { - console.error( - "[MultiSigSubmissionService] Error during cleanup:", - error - ); + console.error("[MultiSigSubmissionService] Error during cleanup:", error); return 0; } } diff --git a/src/services/reputation.service.ts b/src/services/reputation.service.ts index 39052551..cb54ed51 100644 --- a/src/services/reputation.service.ts +++ b/src/services/reputation.service.ts @@ -1,12 +1,16 @@ -import prisma from '../lib/prisma'; +import prisma from "../lib/prisma"; export class ReputationService { - async recordSuccess(providerName: string, endpoint: string | null, latencyMs?: number): Promise { + async recordSuccess( + providerName: string, + endpoint: string | null, + latencyMs?: number, + ): Promise { const existing = await prisma.providerReputation.findUnique({ where: { providerName_endpoint: { providerName, - endpoint: endpoint || '', + endpoint: endpoint || "", }, }, }); @@ -15,23 +19,25 @@ export class ReputationService { const newSuccessfulRequests = (existing?.successfulRequests || 0) + 1; const newConsecutiveFailures = 0; const newConsecutiveIncorrect = 0; - + // Calculate new average latency let newAvgLatency = existing?.averageLatency || null; if (latencyMs && existing?.averageLatency) { - newAvgLatency = (existing.averageLatency * existing.successfulRequests + latencyMs) / newSuccessfulRequests; + newAvgLatency = + (existing.averageLatency * existing.successfulRequests + latencyMs) / + newSuccessfulRequests; } else if (latencyMs) { newAvgLatency = latencyMs; } - + // Calculate reliability score const reliabilityScore = (newSuccessfulRequests / newTotalRequests) * 100; - + await prisma.providerReputation.upsert({ where: { providerName_endpoint: { providerName, - endpoint: endpoint || '', + endpoint: endpoint || "", }, }, update: { @@ -41,14 +47,14 @@ export class ReputationService { lastSuccess: new Date(), consecutiveFailures: newConsecutiveFailures, consecutiveIncorrect: newConsecutiveIncorrect, - status: 'online', + status: "online", reliabilityScore, lastUpdated: new Date(), }, create: { providerName, - endpoint: endpoint || '', - status: 'online', + endpoint: endpoint || "", + status: "online", totalRequests: 1, successfulRequests: 1, averageLatency: latencyMs || null, @@ -58,12 +64,16 @@ export class ReputationService { }); } - async recordFailure(providerName: string, endpoint: string | null, errorType: 'offline' | 'incorrect'): Promise { + async recordFailure( + providerName: string, + endpoint: string | null, + errorType: "offline" | "incorrect", + ): Promise { const existing = await prisma.providerReputation.findUnique({ where: { providerName_endpoint: { providerName, - endpoint: endpoint || '', + endpoint: endpoint || "", }, }, }); @@ -72,28 +82,28 @@ export class ReputationService { const newFailedRequests = (existing?.failedRequests || 0) + 1; let newConsecutiveFailures = (existing?.consecutiveFailures || 0) + 1; let newConsecutiveIncorrect = existing?.consecutiveIncorrect || 0; - - if (errorType === 'incorrect') { + + if (errorType === "incorrect") { newConsecutiveIncorrect = (existing?.consecutiveIncorrect || 0) + 1; } - + // Determine status based on consecutive failures - let status = 'online'; + let status = "online"; if (newConsecutiveFailures >= 5) { - status = 'offline'; + status = "offline"; } else if (newConsecutiveFailures >= 2) { - status = 'degraded'; + status = "degraded"; } - + // Calculate reliability score const successfulRequests = existing?.successfulRequests || 0; const reliabilityScore = (successfulRequests / newTotalRequests) * 100; - + await prisma.providerReputation.upsert({ where: { providerName_endpoint: { providerName, - endpoint: endpoint || '', + endpoint: endpoint || "", }, }, update: { @@ -101,7 +111,7 @@ export class ReputationService { failedRequests: newFailedRequests, lastFailure: new Date(), consecutiveFailures: newConsecutiveFailures, - ...(errorType === 'incorrect' && { + ...(errorType === "incorrect" && { incorrectResponses: (existing?.incorrectResponses || 0) + 1, lastIncorrect: new Date(), consecutiveIncorrect: newConsecutiveIncorrect, @@ -112,13 +122,13 @@ export class ReputationService { }, create: { providerName, - endpoint: endpoint || '', - status: 'degraded', + endpoint: endpoint || "", + status: "degraded", totalRequests: 1, failedRequests: 1, lastFailure: new Date(), consecutiveFailures: 1, - ...(errorType === 'incorrect' && { + ...(errorType === "incorrect" && { incorrectResponses: 1, lastIncorrect: new Date(), consecutiveIncorrect: 1, @@ -133,7 +143,7 @@ export class ReputationService { where: { providerName_endpoint: { providerName, - endpoint: endpoint || '', + endpoint: endpoint || "", }, }, }); @@ -145,22 +155,25 @@ export class ReputationService { reliabilityScore: { lt: threshold }, totalRequests: { gt: 10 }, }, - orderBy: { reliabilityScore: 'asc' }, + orderBy: { reliabilityScore: "asc" }, }); } - async resetConsecutiveFailures(providerName: string, endpoint?: string): Promise { + async resetConsecutiveFailures( + providerName: string, + endpoint?: string, + ): Promise { await prisma.providerReputation.update({ where: { providerName_endpoint: { providerName, - endpoint: endpoint || '', + endpoint: endpoint || "", }, }, data: { consecutiveFailures: 0, consecutiveIncorrect: 0, - status: 'online', + status: "online", }, }); } diff --git a/src/services/sorobanEventListener.ts b/src/services/sorobanEventListener.ts index d4ed65d5..e20fd1a1 100644 --- a/src/services/sorobanEventListener.ts +++ b/src/services/sorobanEventListener.ts @@ -29,7 +29,7 @@ export class SorobanEventListener { process.env.ORACLE_SECRET_KEY || process.env.SOROBAN_ADMIN_SECRET; if (!secret) { throw new Error( - "ORACLE_SECRET_KEY or SOROBAN_ADMIN_SECRET not found in environment variables" + "ORACLE_SECRET_KEY or SOROBAN_ADMIN_SECRET not found in environment variables", ); } @@ -53,7 +53,7 @@ export class SorobanEventListener { this.isRunning = true; console.log( - `[EventListener] Starting listener for account ${this.oraclePublicKey}` + `[EventListener] Starting listener for account ${this.oraclePublicKey}`, ); // Initialize last processed ledger from the most recent on-chain record @@ -63,7 +63,7 @@ export class SorobanEventListener { if (lastRecord) { this.lastProcessedLedger = lastRecord.ledgerSeq; console.log( - `[EventListener] Resuming from ledger ${this.lastProcessedLedger}` + `[EventListener] Resuming from ledger ${this.lastProcessedLedger}`, ); } @@ -129,10 +129,7 @@ export class SorobanEventListener { } } catch (error) { // Account not found is expected for new accounts with no transactions - if ( - error instanceof Error && - error.message.includes("status code 404") - ) { + if (error instanceof Error && error.message.includes("status code 404")) { console.log("[EventListener] No transactions found for oracle account"); return; } @@ -140,9 +137,7 @@ export class SorobanEventListener { } } - private extractMemoId( - tx: ServerApi.TransactionRecord - ): string | null { + private extractMemoId(tx: ServerApi.TransactionRecord): string | null { if (tx.memo_type === "text" && tx.memo) { return tx.memo; } @@ -151,7 +146,7 @@ export class SorobanEventListener { private async parseOperations( tx: ServerApi.TransactionRecord, - memoId: string + memoId: string, ): Promise { const confirmedPrices: ConfirmedPrice[] = []; @@ -184,7 +179,7 @@ export class SorobanEventListener { if (isNaN(rate)) { console.warn( - `[EventListener] Invalid rate value for ${currency}: ${valueStr}` + `[EventListener] Invalid rate value for ${currency}: ${valueStr}`, ); continue; } @@ -201,7 +196,7 @@ export class SorobanEventListener { } catch (error) { console.error( `[EventListener] Error parsing operations for tx ${tx.hash}:`, - error + error, ); } @@ -229,12 +224,12 @@ export class SorobanEventListener { }, }); console.log( - `[EventListener] Saved confirmed price: ${price.currency} = ${price.rate} (tx: ${price.txHash.substring(0, 8)}...)` + `[EventListener] Saved confirmed price: ${price.currency} = ${price.rate} (tx: ${price.txHash.substring(0, 8)}...)`, ); } catch (error) { console.error( `[EventListener] Error saving price for ${price.currency}:`, - error + error, ); } } @@ -258,7 +253,7 @@ export class SorobanEventListener { } async getLatestConfirmedPrice( - currency: string + currency: string, ): Promise { const record = await prisma.onChainPrice.findFirst({ where: { currency: currency.toUpperCase() }, @@ -281,7 +276,7 @@ export class SorobanEventListener { async getConfirmedPriceHistory( currency: string, - limit: number = 100 + limit: number = 100, ): Promise { const records = await prisma.onChainPrice.findMany({ where: { currency: currency.toUpperCase() }, diff --git a/src/services/stellarService.ts b/src/services/stellarService.ts index 1d3c273a..437ecc58 100644 --- a/src/services/stellarService.ts +++ b/src/services/stellarService.ts @@ -21,18 +21,20 @@ export class StellarService { private readonly RETRY_DELAY_MS = 2000; // 2 seconds delay between retries constructor() { - const secret = process.env.ORACLE_SECRET_KEY || process.env.SOROBAN_ADMIN_SECRET; + const secret = + process.env.ORACLE_SECRET_KEY || process.env.SOROBAN_ADMIN_SECRET; if (!secret) { throw new Error("Stellar secret key not found in environment variables"); } this.keypair = Keypair.fromSecret(secret); this.network = process.env.STELLAR_NETWORK || "TESTNET"; - - const horizonUrl = this.network === "PUBLIC" - ? "https://horizon.stellar.org" - : "https://horizon-testnet.stellar.org"; - + + const horizonUrl = + this.network === "PUBLIC" + ? "https://horizon.stellar.org" + : "https://horizon-testnet.stellar.org"; + this.server = new Horizon.Server(horizonUrl); } @@ -55,30 +57,83 @@ export class StellarService { * @param price - The current price/rate * @param memoId - Unique ID for auditing */ - async submitPriceUpdate(currency: string, price: number, memoId: string): Promise { + async submitPriceUpdate( + currency: string, + price: number, + memoId: string, + ): Promise { const baseFee = parseInt(await this.getRecommendedFee(), 10); - + const result = await this.submitTransactionWithRetries( (sourceAccount, currentFee) => { return new TransactionBuilder(sourceAccount, { fee: currentFee.toString(), - networkPassphrase: this.network === "PUBLIC" ? Networks.PUBLIC : Networks.TESTNET, + networkPassphrase: + this.network === "PUBLIC" ? Networks.PUBLIC : Networks.TESTNET, }) .addOperation( Operation.manageData({ name: `${currency}_PRICE`, value: price.toString(), - }) + }), ) .addMemo(Memo.text(memoId)) .setTimeout(60) .build(); }, this.MAX_RETRIES, - baseFee + baseFee, + ); + + console.info( + `✅ Price update for ${currency} confirmed. Hash: ${result.hash}`, + ); + return result.hash; + } + + /** + * Submit multiple price updates to the Stellar network in a single bundle transaction. + * Leverages submitTransactionWithRetries for automatic fee bumping if stuck. + * @param updates - Array of price updates { currency, price } + * @param memoId - Unique ID for auditing + */ + async submitBatchedPriceUpdates( + updates: Array<{ currency: string; price: number }>, + memoId: string, + ): Promise { + if (updates.length === 0) { + throw new Error("Cannot submit empty batch of price updates"); + } + + const baseFee = parseInt(await this.getRecommendedFee(), 10); + + const result = await this.submitTransactionWithRetries( + (sourceAccount, currentFee) => { + const builder = new TransactionBuilder(sourceAccount, { + fee: currentFee.toString(), + networkPassphrase: + this.network === "PUBLIC" ? Networks.PUBLIC : Networks.TESTNET, + }); + + for (const update of updates) { + builder.addOperation( + Operation.manageData({ + name: `${update.currency}_PRICE`, + value: update.price.toString(), + }), + ); + } + + return builder.addMemo(Memo.text(memoId)).setTimeout(60).build(); + }, + this.MAX_RETRIES, + baseFee, + ); + + const currencies = updates.map((u) => u.currency).join(", "); + console.info( + `✅ Batched price update for [${currencies}] confirmed. Hash: ${result.hash}`, ); - - console.info(`✅ Price update for ${currency} confirmed. Hash: ${result.hash}`); return result.hash; } @@ -94,21 +149,22 @@ export class StellarService { currency: string, price: number, memoId: string, - signatures: Array<{ signerPublicKey: string; signature: string }> + signatures: Array<{ signerPublicKey: string; signature: string }>, ): Promise { const baseFee = parseInt(await this.getRecommendedFee(), 10); - + const result = await this.submitMultiSignedTransaction( (sourceAccount, currentFee) => { return new TransactionBuilder(sourceAccount, { fee: currentFee.toString(), - networkPassphrase: this.network === "PUBLIC" ? Networks.PUBLIC : Networks.TESTNET, + networkPassphrase: + this.network === "PUBLIC" ? Networks.PUBLIC : Networks.TESTNET, }) .addOperation( Operation.manageData({ name: `${currency}_PRICE`, value: price.toString(), - }) + }), ) .addMemo(Memo.text(memoId)) .setTimeout(60) @@ -116,10 +172,12 @@ export class StellarService { }, signatures, this.MAX_RETRIES, - baseFee + baseFee, + ); + + console.info( + `✅ Multi-signed price update for ${currency} confirmed. Hash: ${result.hash}`, ); - - console.info(`✅ Multi-signed price update for ${currency} confirmed. Hash: ${result.hash}`); return result.hash; } @@ -131,37 +189,50 @@ export class StellarService { * @param baseFee - The starting fee in stroops */ async submitTransactionWithRetries( - builderFn: (sourceAccount: Horizon.AccountResponse, currentFee: number) => Transaction, + builderFn: ( + sourceAccount: Horizon.AccountResponse, + currentFee: number, + ) => Transaction, maxRetries = this.MAX_RETRIES, - baseFee: number + baseFee: number, ): Promise { let attempt = 0; - + while (attempt <= maxRetries) { try { - const sourceAccount = await this.server.loadAccount(this.keypair.publicKey()); - const currentFee = Math.floor(baseFee * (1 + (this.FEE_INCREMENT_PERCENTAGE * attempt))); - + const sourceAccount = await this.server.loadAccount( + this.keypair.publicKey(), + ); + const currentFee = Math.floor( + baseFee * (1 + this.FEE_INCREMENT_PERCENTAGE * attempt), + ); + const transaction = builderFn(sourceAccount, currentFee); transaction.sign(this.keypair); - + return await this.server.submitTransaction(transaction); } catch (error: any) { attempt++; - + const isStuck = this.isStuckError(error); - + if (isStuck && attempt <= maxRetries) { - console.warn(`⚠️ Transaction stuck or fee too low (Attempt ${attempt}). Bumping fee and retrying in ${this.RETRY_DELAY_MS}ms...`); - await new Promise(resolve => setTimeout(resolve, this.RETRY_DELAY_MS)); + console.warn( + `⚠️ Transaction stuck or fee too low (Attempt ${attempt}). Bumping fee and retrying in ${this.RETRY_DELAY_MS}ms...`, + ); + await new Promise((resolve) => + setTimeout(resolve, this.RETRY_DELAY_MS), + ); continue; } throw error; } } - - throw new Error(`Failed to submit transaction after ${maxRetries + 1} attempts`); + + throw new Error( + `Failed to submit transaction after ${maxRetries + 1} attempts`, + ); } /** @@ -173,75 +244,84 @@ export class StellarService { * @param baseFee - The starting fee in stroops */ private async submitMultiSignedTransaction( - builderFn: (sourceAccount: Horizon.AccountResponse, currentFee: number) => Transaction, + builderFn: ( + sourceAccount: Horizon.AccountResponse, + currentFee: number, + ) => Transaction, signatures: Array<{ signerPublicKey: string; signature: string }>, maxRetries = this.MAX_RETRIES, - baseFee: number + baseFee: number, ): Promise { let attempt = 0; - + while (attempt <= maxRetries) { try { - const sourceAccount = await this.server.loadAccount(this.keypair.publicKey()); - const currentFee = Math.floor(baseFee * (1 + (this.FEE_INCREMENT_PERCENTAGE * attempt))); - + const sourceAccount = await this.server.loadAccount( + this.keypair.publicKey(), + ); + const currentFee = Math.floor( + baseFee * (1 + this.FEE_INCREMENT_PERCENTAGE * attempt), + ); + const transaction = builderFn(sourceAccount, currentFee); - + // Sign with the local keypair first transaction.sign(this.keypair); - + // Add signatures from other signers for (const sig of signatures) { // Skip if this is the local signer's public key (already signed) if (sig.signerPublicKey === this.keypair.publicKey()) { continue; } - + // Add the remote signature to the transaction try { // Convert hex signature to buffer const signatureBuffer = Buffer.from(sig.signature, "hex"); - + // Create a keypair from the signer's public key to get the hint const signerKeypair = Keypair.fromPublicKey(sig.signerPublicKey); - - // Get the signature hint - const hint = signerKeypair.signatureHint(); - + // Add the signature to the transaction const decoratedSignature = new xdr.DecoratedSignature({ hint: signerKeypair.signatureHint(), signature: signatureBuffer, }); - + transaction.signatures.push(decoratedSignature); - } catch (error) { console.error( `[StellarService] Failed to add signature for ${sig.signerPublicKey}:`, - error + error, ); // Continue without this signature (may cause failure on Stellar side) } } - + return await this.server.submitTransaction(transaction); } catch (error: any) { attempt++; - + const isStuck = this.isStuckError(error); - + if (isStuck && attempt <= maxRetries) { - console.warn(`⚠️ Multi-sig transaction stuck or fee too low (Attempt ${attempt}). Bumping fee and retrying in ${this.RETRY_DELAY_MS}ms...`); - await new Promise(resolve => setTimeout(resolve, this.RETRY_DELAY_MS)); + console.warn( + `⚠️ Multi-sig transaction stuck or fee too low (Attempt ${attempt}). Bumping fee and retrying in ${this.RETRY_DELAY_MS}ms...`, + ); + await new Promise((resolve) => + setTimeout(resolve, this.RETRY_DELAY_MS), + ); continue; } throw error; } } - - throw new Error(`Failed to submit multi-signed transaction after ${maxRetries + 1} attempts`); + + throw new Error( + `Failed to submit multi-signed transaction after ${maxRetries + 1} attempts`, + ); } /** @@ -257,17 +337,17 @@ export class StellarService { */ private isStuckError(error: any): boolean { const resultCode = error.response?.data?.extras?.result_codes?.transaction; - + // tx_too_late: Transaction timebounds expired before inclusion // tx_insufficient_fee: Mandatory fee not met // tx_bad_seq: Sequence number mismatch (often due to race conditions/congestion) // timeout: Network/SDK timeout during submission return ( - resultCode === 'tx_too_late' || - resultCode === 'tx_insufficient_fee' || - resultCode === 'tx_bad_seq' || - error.message?.includes('timeout') || - error.code === 'ECONNABORTED' + resultCode === "tx_too_late" || + resultCode === "tx_insufficient_fee" || + resultCode === "tx_bad_seq" || + error.message?.includes("timeout") || + error.code === "ECONNABORTED" ); } @@ -277,7 +357,9 @@ export class StellarService { */ generateMemoId(currency: string): string { const timestamp = Math.floor(Date.now() / 1000); - const random = Math.floor(Math.random() * 1000).toString().padStart(3, '0'); + const random = Math.floor(Math.random() * 1000) + .toString() + .padStart(3, "0"); // Stellar MemoText limit is 28 bytes const id = `SF-${currency}-${timestamp}-${random}`; return id.substring(0, 28); diff --git a/src/services/webhook.ts b/src/services/webhook.ts index 265e9191..2ac65eb4 100644 --- a/src/services/webhook.ts +++ b/src/services/webhook.ts @@ -121,7 +121,10 @@ export class WebhookService { }, ); } catch (error) { - console.error("Failed to send webhook notification after retries:", error); + console.error( + "Failed to send webhook notification after retries:", + error, + ); } } diff --git a/src/utils/retryUtil.ts b/src/utils/retryUtil.ts index 6c72a9e9..554e7a81 100644 --- a/src/utils/retryUtil.ts +++ b/src/utils/retryUtil.ts @@ -44,7 +44,9 @@ const DEFAULT_RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]; /** * Default retry configuration */ -const DEFAULT_RETRY_CONFIG: Required> = { +const DEFAULT_RETRY_CONFIG: Required< + Omit +> = { maxRetries: 3, retryDelay: 1000, exponentialBackoff: false, @@ -56,7 +58,7 @@ const DEFAULT_RETRY_CONFIG: Required> + config: Required>, ): boolean { // Network errors (no response received) if (!error.response) { @@ -74,7 +76,7 @@ function shouldRetryError( function calculateDelay( attempt: number, baseDelay: number, - exponentialBackoff: boolean + exponentialBackoff: boolean, ): number { if (exponentialBackoff) { return baseDelay * Math.pow(2, attempt - 1); @@ -91,11 +93,11 @@ function delay(ms: number): Promise { /** * Wraps an axios request with automatic retry logic - * + * * @param requestFn - Function that returns an axios request promise * @param config - Retry configuration options * @returns Promise that resolves with the axios response - * + * * @example * ```typescript * const response = await withRetry( @@ -106,7 +108,7 @@ function delay(ms: number): Promise { */ export async function withRetry( requestFn: () => Promise, - config: RetryConfig = {} + config: RetryConfig = {}, ): Promise { const mergedConfig = { ...DEFAULT_RETRY_CONFIG, @@ -148,7 +150,7 @@ export async function withRetry( const retryDelay = calculateDelay( attempt, mergedConfig.retryDelay, - mergedConfig.exponentialBackoff + mergedConfig.exponentialBackoff, ); // Call onRetry callback if provided @@ -157,7 +159,7 @@ export async function withRetry( } else { console.debug( `Retry attempt ${attempt}/${mergedConfig.maxRetries} after ${retryDelay}ms delay. ` + - `Error: ${axiosError.message}` + `Error: ${axiosError.message}`, ); } @@ -172,24 +174,24 @@ export async function withRetry( /** * Creates an axios instance with built-in retry logic - * + * * @param axiosConfig - Axios configuration * @param retryConfig - Retry configuration * @returns Axios instance with retry interceptor - * + * * @example * ```typescript * const client = createRetryableAxiosInstance( * { timeout: 5000 }, * { maxRetries: 3, retryDelay: 1000 } * ); - * + * * const response = await client.get('https://api.example.com/data'); * ``` */ export function createRetryableAxiosInstance( axiosConfig: AxiosRequestConfig = {}, - retryConfig: RetryConfig = {} + retryConfig: RetryConfig = {}, ) { const instance = axios.create({ timeout: OUTGOING_HTTP_TIMEOUT_MS, @@ -232,7 +234,7 @@ export function createRetryableAxiosInstance( const retryDelay = calculateDelay( retryCount + 1, mergedConfig.retryDelay, - mergedConfig.exponentialBackoff + mergedConfig.exponentialBackoff, ); // Call onRetry callback @@ -241,14 +243,14 @@ export function createRetryableAxiosInstance( } else { console.debug( `Retry attempt ${retryCount + 1}/${mergedConfig.maxRetries} after ${retryDelay}ms delay. ` + - `Error: ${error.message}` + `Error: ${error.message}`, ); } // Wait and retry await delay(retryDelay); return instance.request(config); - } + }, ); return instance; diff --git a/src/utils/timeUtils.ts b/src/utils/timeUtils.ts index 3f5fa461..c86d1afc 100644 --- a/src/utils/timeUtils.ts +++ b/src/utils/timeUtils.ts @@ -6,7 +6,8 @@ * parsing strings or Date objects. */ export function normalizeDateToUTC(value: Date | string | number): Date { - const date = value instanceof Date ? new Date(value.getTime()) : new Date(value); + const date = + value instanceof Date ? new Date(value.getTime()) : new Date(value); if (Number.isNaN(date.getTime())) { throw new Error(`Invalid date value provided: ${value}`); diff --git a/test/calculateAverage.test.ts b/test/calculateAverage.test.ts index a65895ad..08ba8cd8 100644 --- a/test/calculateAverage.test.ts +++ b/test/calculateAverage.test.ts @@ -2,7 +2,10 @@ * Unit tests for calculateAverage * Run with: npx tsx test/calculateAverage.test.ts */ -import { calculateAverage, calculateWeightedAverage } from '../src/services/marketRate/types.js'; +import { + calculateAverage, + calculateWeightedAverage, +} from "../src/services/marketRate/types.js"; let passed = 0; let failed = 0; @@ -21,57 +24,57 @@ function assert(description: string, actual: number, expected: number): void { } } -console.log('\n🧮 calculateAverage — unit tests\n'); +console.log("\n🧮 calculateAverage — unit tests\n"); // 1. Empty array must not crash and must return 0 -assert('empty array returns 0', calculateAverage([]), 0); +assert("empty array returns 0", calculateAverage([]), 0); // 2. Single price is the average of itself -assert('single price [1500] → 1500', calculateAverage([1500]), 1500); +assert("single price [1500] → 1500", calculateAverage([1500]), 1500); // 3. Two prices -assert('two prices [1000, 2000] → 1500', calculateAverage([1000, 2000]), 1500); +assert("two prices [1000, 2000] → 1500", calculateAverage([1000, 2000]), 1500); // 4. Three NGN prices (the task's canonical scenario) assert( - 'three NGN prices [1580, 1600, 1620] → 1600', + "three NGN prices [1580, 1600, 1620] → 1600", calculateAverage([1580, 1600, 1620]), 1600, ); // 5. Floating-point inputs assert( - 'float prices [1.1, 2.2, 3.3] → 2.2', + "float prices [1.1, 2.2, 3.3] → 2.2", calculateAverage([1.1, 2.2, 3.3]), 2.2, ); // 6. All identical prices assert( - 'identical prices [500, 500, 500] → 500', + "identical prices [500, 500, 500] → 500", calculateAverage([500, 500, 500]), 500, ); // 7. Zero included -assert('prices with zero [0, 300] → 150', calculateAverage([0, 300]), 150); +assert("prices with zero [0, 300] → 150", calculateAverage([0, 300]), 150); // 8. Weighted average prefers trusted values over new values assert( - 'weighted average uses trust tiers', + "weighted average uses trust tiers", calculateWeightedAverage([ - { value: 100, trustLevel: 'new' }, - { value: 200, trustLevel: 'trusted' }, + { value: 100, trustLevel: "new" }, + { value: 200, trustLevel: "trusted" }, ]), 175, ); // 9. Explicit weight overrides trust tier assert( - 'weighted average uses explicit weight override', + "weighted average uses explicit weight override", calculateWeightedAverage([ - { value: 100, trustLevel: 'trusted', weight: 1 }, - { value: 200, trustLevel: 'new', weight: 3 }, + { value: 100, trustLevel: "trusted", weight: 1 }, + { value: 200, trustLevel: "new", weight: 3 }, ]), 175, ); diff --git a/test/ghsFetcher.test.ts b/test/ghsFetcher.test.ts index e9e7ec9c..e69de29b 100644 --- a/test/ghsFetcher.test.ts +++ b/test/ghsFetcher.test.ts @@ -1,106 +0,0 @@ -import assert from 'node:assert/strict'; -import axios from 'axios'; -import { GHSRateFetcher } from '../src/services/marketRate/ghsFetcher'; - -async function run() { - const originalGet = axios.get; - - try { - const fetcher = new GHSRateFetcher(); - - axios.get = (async (url: string) => { - if (url.includes('api.coingecko.com')) { - return { - data: { - stellar: { - ghs: 2.45, - usd: 0.18, - last_updated_at: 1_774_464_561 - } - } - }; - } - - throw new Error(`Unexpected URL: ${url}`); - }) as typeof axios.get; - - const directRate = await fetcher.fetchRate(); - assert.equal(directRate.currency, 'GHS'); - assert.equal(directRate.rate, 2.45); - assert.equal(directRate.source, 'Weighted average of 1 sources (outliers filtered)'); - - let exchangeRateRequested = false; - - axios.get = (async (url: string) => { - if (url.includes('api.coingecko.com')) { - return { - data: { - stellar: { - usd: 0.2, - last_updated_at: 1_774_464_561 - } - } - }; - } - - if (url.includes('open.er-api.com')) { - exchangeRateRequested = true; - return { - data: { - result: 'success', - rates: { - GHS: 10 - }, - time_last_update_unix: 1_774_396_951 - } - }; - } - - throw new Error(`Unexpected URL: ${url}`); - }) as typeof axios.get; - - const fallbackRate = await fetcher.fetchRate(); - assert.equal(exchangeRateRequested, true); - assert.equal(fallbackRate.currency, 'GHS'); - assert.equal(fallbackRate.rate, 2); - assert.equal(fallbackRate.source, 'CoinGecko + ExchangeRate API'); - - axios.get = (async (url: string) => { - if (url.includes('api.coingecko.com')) { - return { - data: { - stellar: { - ghs: Number.NaN, - usd: 0.2, - last_updated_at: 1_774_464_561 - } - } - }; - } - - if (url.includes('open.er-api.com')) { - return { - data: { - result: 'success', - rates: { - GHS: undefined - }, - time_last_update_unix: 1_774_396_951 - } - }; - } - - throw new Error(`Unexpected URL: ${url}`); - }) as typeof axios.get; - - await assert.rejects(() => fetcher.fetchRate(), /Price must be a positive number|usable GHS rate/); - } finally { - axios.get = originalGet; - } -} - -run().catch((error) => { - console.error(error); - process.exitCode = 1; -}); - diff --git a/test/history.test.ts b/test/history.test.ts index 36fdf4e1..3fcb20f2 100644 --- a/test/history.test.ts +++ b/test/history.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Request, Response } from 'express'; -import historyRouter from '../src/routes/history'; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Request, Response } from "express"; +import historyRouter from "../src/routes/history"; // Mock Prisma const mockPrisma = { @@ -10,17 +10,17 @@ const mockPrisma = { }; // Mock the prisma module -vi.mock('../src/lib/prisma', () => ({ +vi.mock("../src/lib/prisma", () => ({ default: mockPrisma, })); -describe('GET /api/v1/history/:asset', () => { +describe("GET /api/v1/history/:asset", () => { let mockRequest: Partial; let mockResponse: Partial; beforeEach(() => { mockRequest = { - params: { asset: 'NGN' }, + params: { asset: "NGN" }, query: {}, }; mockResponse = { @@ -30,24 +30,24 @@ describe('GET /api/v1/history/:asset', () => { vi.clearAllMocks(); }); - it('should filter by from and to dates', async () => { - const from = '2024-01-01T00:00:00Z'; - const to = '2024-01-07T23:59:59Z'; + it("should filter by from and to dates", async () => { + const from = "2024-01-01T00:00:00Z"; + const to = "2024-01-07T23:59:59Z"; mockRequest.query = { from, to }; mockPrisma.priceHistory.findMany.mockResolvedValue([ - { timestamp: new Date(from), rate: 100, source: 'test' } + { timestamp: new Date(from), rate: 100, source: "test" }, ]); const handler = historyRouter.stack.find( - (layer: any) => layer.route?.path === '/:asset' + (layer: any) => layer.route?.path === "/:asset", )?.route?.stack[0]?.handle; await handler(mockRequest as Request, mockResponse as Response); expect(mockPrisma.priceHistory.findMany).toHaveBeenCalledWith({ where: { - currency: 'NGN', + currency: "NGN", timestamp: { gte: new Date(from), lte: new Date(to), @@ -59,44 +59,48 @@ describe('GET /api/v1/history/:asset', () => { expect(mockResponse.json).toHaveBeenCalledWith({ success: true, - asset: 'NGN', - range: 'custom', - data: [{ - timestamp: new Date(from).toISOString(), - rate: 100, - source: 'test' - }] + asset: "NGN", + range: "custom", + data: [ + { + timestamp: new Date(from).toISOString(), + rate: 100, + source: "test", + }, + ], }); }); - it('should use range if from/to are not provided', async () => { - mockRequest.query = { range: '1d' }; + it("should use range if from/to are not provided", async () => { + mockRequest.query = { range: "1d" }; mockPrisma.priceHistory.findMany.mockResolvedValue([ - { timestamp: new Date(), rate: 100, source: 'test' } + { timestamp: new Date(), rate: 100, source: "test" }, ]); const handler = historyRouter.stack.find( - (layer: any) => layer.route?.path === '/:asset' + (layer: any) => layer.route?.path === "/:asset", )?.route?.stack[0]?.handle; await handler(mockRequest as Request, mockResponse as Response); - expect(mockPrisma.priceHistory.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - currency: 'NGN', - timestamp: expect.objectContaining({ - gte: expect.any(Date), + expect(mockPrisma.priceHistory.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + currency: "NGN", + timestamp: expect.objectContaining({ + gte: expect.any(Date), + }), }), }), - })); + ); }); - it('should return 400 for invalid dates', async () => { - mockRequest.query = { from: 'invalid-date' }; + it("should return 400 for invalid dates", async () => { + mockRequest.query = { from: "invalid-date" }; const handler = historyRouter.stack.find( - (layer: any) => layer.route?.path === '/:asset' + (layer: any) => layer.route?.path === "/:asset", )?.route?.stack[0]?.handle; await handler(mockRequest as Request, mockResponse as Response); @@ -104,16 +108,16 @@ describe('GET /api/v1/history/:asset', () => { expect(mockResponse.status).toHaveBeenCalledWith(400); expect(mockResponse.json).toHaveBeenCalledWith({ success: false, - error: "Invalid 'from' date" + error: "Invalid 'from' date", }); }); - it('should return 404 when no records found', async () => { - mockRequest.query = { from: '2024-01-01', to: '2024-01-07' }; + it("should return 404 when no records found", async () => { + mockRequest.query = { from: "2024-01-01", to: "2024-01-07" }; mockPrisma.priceHistory.findMany.mockResolvedValue([]); const handler = historyRouter.stack.find( - (layer: any) => layer.route?.path === '/:asset' + (layer: any) => layer.route?.path === "/:asset", )?.route?.stack[0]?.handle; await handler(mockRequest as Request, mockResponse as Response); @@ -121,7 +125,7 @@ describe('GET /api/v1/history/:asset', () => { expect(mockResponse.status).toHaveBeenCalledWith(404); expect(mockResponse.json).toHaveBeenCalledWith({ success: false, - error: "No history found for NGN in the specified timeframe" + error: "No history found for NGN in the specified timeframe", }); }); }); diff --git a/test/intelligence.test.ts b/test/intelligence.test.ts index a45e3cca..182f6d2d 100644 --- a/test/intelligence.test.ts +++ b/test/intelligence.test.ts @@ -1,35 +1,43 @@ -import assert from 'node:assert/strict'; -import { IntelligenceService } from '../src/services/intelligenceService'; +import assert from "node:assert/strict"; +import { IntelligenceService } from "../src/services/intelligenceService"; // Mock Prisma Client const mockPrisma = { priceHistory: { findFirst: async (params: any) => { // Logic for Test Case 1: Positive change - if (params.where.currency === 'NGN_POS') { + if (params.where.currency === "NGN_POS") { if (params.where.timestamp?.lte) { - return { id: 1, rate: 1500, timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000) }; + return { + id: 1, + rate: 1500, + timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000), + }; } return { id: 2, rate: 1530, timestamp: new Date() }; } - + // Logic for Test Case 2: Negative change - if (params.where.currency === 'NGN_NEG') { + if (params.where.currency === "NGN_NEG") { if (params.where.timestamp?.lte) { - return { id: 1, rate: 1500, timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000) }; + return { + id: 1, + rate: 1500, + timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000), + }; } return { id: 2, rate: 1485, timestamp: new Date() }; } // Logic for Test Case 3: No historical data - if (params.where.currency === 'NGN_NEW') { + if (params.where.currency === "NGN_NEW") { if (params.where.timestamp?.lte) return null; return { id: 1, rate: 1500, timestamp: new Date() }; } return null; - } - } + }, + }, }; // Re-inject mock prisma into the service if possible or use a factory @@ -40,7 +48,7 @@ const mockPrisma = { class TestableIntelligenceService extends IntelligenceService { // @ts-ignore private prisma = mockPrisma; - + // Override method to use mock prisma async calculate24hPriceChange(currency: string): Promise { const asset = currency.toUpperCase(); @@ -62,7 +70,7 @@ class TestableIntelligenceService extends IntelligenceService { orderBy: { timestamp: "desc" }, }); - const baseRecord = historicalRecord; + const baseRecord = historicalRecord; if (!baseRecord || baseRecord.id === latestRecord.id) { return "0.0%"; @@ -85,27 +93,27 @@ class TestableIntelligenceService extends IntelligenceService { async function run() { const service = new TestableIntelligenceService(); - console.log('🧪 Testing IntelligenceService: 24h Price Change...'); + console.log("🧪 Testing IntelligenceService: 24h Price Change..."); // Test 1: Positive change (1530 vs 1500 = +2.0%) - const posChange = await service.calculate24hPriceChange('NGN_POS'); + const posChange = await service.calculate24hPriceChange("NGN_POS"); console.log(`Test 1 (Positive): ${posChange}`); - assert.equal(posChange, '+2.0%'); + assert.equal(posChange, "+2.0%"); // Test 2: Negative change (1485 vs 1500 = -1.0%) - const negChange = await service.calculate24hPriceChange('NGN_NEG'); + const negChange = await service.calculate24hPriceChange("NGN_NEG"); console.log(`Test 2 (Negative): ${negChange}`); - assert.equal(negChange, '-1.0%'); + assert.equal(negChange, "-1.0%"); // Test 3: No history - const newChange = await service.calculate24hPriceChange('NGN_NEW'); + const newChange = await service.calculate24hPriceChange("NGN_NEW"); console.log(`Test 3 (New): ${newChange}`); - assert.equal(newChange, '0.0%'); + assert.equal(newChange, "0.0%"); - console.log('✅ All IntelligenceService tests passed!'); + console.log("✅ All IntelligenceService tests passed!"); } run().catch((err) => { - console.error('❌ Test failed:', err); + console.error("❌ Test failed:", err); process.exit(1); }); diff --git a/test/marketRates.test.d.ts b/test/marketRates.test.d.ts index 9fd8a19e..58afdb58 100644 --- a/test/marketRates.test.d.ts +++ b/test/marketRates.test.d.ts @@ -1,2 +1,2 @@ export {}; -//# sourceMappingURL=marketRates.test.d.ts.map \ No newline at end of file +//# sourceMappingURL=marketRates.test.d.ts.map diff --git a/test/marketRates.test.js b/test/marketRates.test.js index 899438b0..22eb135a 100644 --- a/test/marketRates.test.js +++ b/test/marketRates.test.js @@ -1,50 +1,47 @@ -import { MarketRateService } from '../src/services/marketRate'; +import { MarketRateService } from "../src/services/marketRate"; async function testMarketRates() { - console.log('🧪 Testing Market Rate Fetchers...\n'); - const service = new MarketRateService(); - // Test supported currencies - console.log('📋 Supported currencies:', service.getSupportedCurrencies()); - console.log(); - // Test health check - console.log('🏥 Health check:'); - const health = await service.healthCheck(); - console.log(health); - console.log(); - // Test fetching KES rate - console.log('🇰🇪 Fetching KES rate...'); - try { - const kesResult = await service.getRate('KES'); - console.log('KES Result:', kesResult); - } - catch (error) { - console.log('KES Error:', error); - } - console.log(); - // Test fetching GHS rate - console.log('🇬🇭 Fetching GHS rate...'); - try { - const ghsResult = await service.getRate('GHS'); - console.log('GHS Result:', ghsResult); - } - catch (error) { - console.log('GHS Error:', error); - } - console.log(); - // Test fetching all rates - console.log('📊 Fetching all rates...'); - try { - const allRates = await service.getAllRates(); - console.log('All Rates:', allRates); - } - catch (error) { - console.log('All Rates Error:', error); - } - console.log(); - // Test cache status - console.log('💾 Cache status:'); - const cacheStatus = service.getCacheStatus(); - console.log(cacheStatus); + console.log("🧪 Testing Market Rate Fetchers...\n"); + const service = new MarketRateService(); + // Test supported currencies + console.log("📋 Supported currencies:", service.getSupportedCurrencies()); + console.log(); + // Test health check + console.log("🏥 Health check:"); + const health = await service.healthCheck(); + console.log(health); + console.log(); + // Test fetching KES rate + console.log("🇰🇪 Fetching KES rate..."); + try { + const kesResult = await service.getRate("KES"); + console.log("KES Result:", kesResult); + } catch (error) { + console.log("KES Error:", error); + } + console.log(); + // Test fetching GHS rate + console.log("🇬🇭 Fetching GHS rate..."); + try { + const ghsResult = await service.getRate("GHS"); + console.log("GHS Result:", ghsResult); + } catch (error) { + console.log("GHS Error:", error); + } + console.log(); + // Test fetching all rates + console.log("📊 Fetching all rates..."); + try { + const allRates = await service.getAllRates(); + console.log("All Rates:", allRates); + } catch (error) { + console.log("All Rates Error:", error); + } + console.log(); + // Test cache status + console.log("💾 Cache status:"); + const cacheStatus = service.getCacheStatus(); + console.log(cacheStatus); } // Run tests testMarketRates().catch(console.error); -//# sourceMappingURL=marketRates.test.js.map \ No newline at end of file +//# sourceMappingURL=marketRates.test.js.map diff --git a/test/marketRates.test.ts b/test/marketRates.test.ts index 3f7f388c..8b5f008d 100644 --- a/test/marketRates.test.ts +++ b/test/marketRates.test.ts @@ -1,64 +1,66 @@ -import { MarketRateService } from '../src/services/marketRate'; -import { normalizeDateToUTC } from '../src/utils/timeUtils'; +import { MarketRateService } from "../src/services/marketRate"; +import { normalizeDateToUTC } from "../src/utils/timeUtils"; function testUtcNormalization() { - const input = '2026-01-01T00:00:00-05:00'; + const input = "2026-01-01T00:00:00-05:00"; const normalized = normalizeDateToUTC(new Date(input)); - const expected = '2026-01-01T05:00:00.000Z'; + const expected = "2026-01-01T05:00:00.000Z"; if (normalized.toISOString() !== expected) { - throw new Error(`UTC normalization failed (got ${normalized.toISOString()}, expected ${expected})`); + throw new Error( + `UTC normalization failed (got ${normalized.toISOString()}, expected ${expected})`, + ); } - console.log('✅ UTC normalization helper passed.'); + console.log("✅ UTC normalization helper passed."); } async function testMarketRates() { testUtcNormalization(); - console.log('🧪 Testing Market Rate Fetchers...\n'); - + console.log("🧪 Testing Market Rate Fetchers...\n"); + const service = new MarketRateService(); - + // Test supported currencies - console.log('📋 Supported currencies:', service.getSupportedCurrencies()); + console.log("📋 Supported currencies:", service.getSupportedCurrencies()); console.log(); - + // Test health check - console.log('🏥 Health check:'); + console.log("🏥 Health check:"); const health = await service.healthCheck(); console.log(health); console.log(); - + // Test fetching KES rate - console.log('🇰🇪 Fetching KES rate...'); + console.log("🇰🇪 Fetching KES rate..."); try { - const kesResult = await service.getRate('KES'); - console.log('KES Result:', kesResult); + const kesResult = await service.getRate("KES"); + console.log("KES Result:", kesResult); } catch (error) { - console.log('KES Error:', error); + console.log("KES Error:", error); } console.log(); - + // Test fetching GHS rate - console.log('🇬🇭 Fetching GHS rate...'); + console.log("🇬🇭 Fetching GHS rate..."); try { - const ghsResult = await service.getRate('GHS'); - console.log('GHS Result:', ghsResult); + const ghsResult = await service.getRate("GHS"); + console.log("GHS Result:", ghsResult); } catch (error) { - console.log('GHS Error:', error); + console.log("GHS Error:", error); } console.log(); - + // Test fetching all rates - console.log('📊 Fetching all rates...'); + console.log("📊 Fetching all rates..."); try { const allRates = await service.getAllRates(); - console.log('All Rates:', allRates); + console.log("All Rates:", allRates); } catch (error) { - console.log('All Rates Error:', error); + console.log("All Rates Error:", error); } console.log(); - + // Test cache status - console.log('💾 Cache status:'); + console.log("💾 Cache status:"); const cacheStatus = service.getCacheStatus(); console.log(cacheStatus); } diff --git a/test/ngnFetcher.test.ts b/test/ngnFetcher.test.ts index ba0389d5..a4faf58e 100644 --- a/test/ngnFetcher.test.ts +++ b/test/ngnFetcher.test.ts @@ -77,7 +77,10 @@ async function run() { assert.equal(rate.currency, "NGN"); assert.equal(rate.rate, expectedRate); - assert.equal(rate.source, "Weighted average of 3 sources (outliers filtered)"); + assert.equal( + rate.source, + "Weighted average of 3 sources (outliers filtered)", + ); } finally { axios.get = originalGet; for (const [key, val] of Object.entries(savedEnv)) { diff --git a/test/responseCaching.test.ts b/test/responseCaching.test.ts index 69ce37ef..e69de29b 100644 --- a/test/responseCaching.test.ts +++ b/test/responseCaching.test.ts @@ -1,112 +0,0 @@ -import { MarketRateService } from "../src/services/marketRate"; -import { MarketRate } from "../src/services/marketRate"; - -function assert(condition: boolean, message: string): void { - if (!condition) { - throw new Error(message); - } -} - -async function run(): Promise { - const service = Object.create(MarketRateService.prototype) as any; - service.databaseCalls = 0; - service.redisData = new Map(); - service.cache = new Map(); - service.LATEST_PRICES_REDIS_KEY = "market-rates:latest:v1"; - service.LATEST_PRICES_REDIS_TTL_SECONDS = 5; - service.getLatestPricesCacheClient = () => ({ - get: async (key: string): Promise => { - const entry = service.redisData.get(key); - if (!entry || entry.expiresAt <= Date.now()) { - service.redisData.delete(key); - return null; - } - - return entry.value; - }, - setEx: async ( - key: string, - ttlSeconds: number, - value: string, - ): Promise => { - service.redisData.set(key, { - value, - expiresAt: Date.now() + ttlSeconds * 1000, - }); - }, - del: async (key: string): Promise => { - service.redisData.delete(key); - }, - }); - service.fetchLatestPricesFromDatabase = async (): Promise => { - service.databaseCalls += 1; - return [ - { - currency: "KES", - rate: 150, - timestamp: new Date("2026-03-27T12:00:00.000Z"), - source: "test", - }, - { - currency: "GHS", - rate: 15, - timestamp: new Date("2026-03-27T12:00:00.000Z"), - source: "test", - }, - ]; - }; - - const firstResponse = await service.getLatestPrices(); - const secondResponse = await service.getLatestPrices(); - - assert(firstResponse.success, "first latest-prices response should succeed"); - assert( - secondResponse.success, - "second latest-prices response should succeed", - ); - assert( - service.databaseCalls === 1, - `expected Redis cache hit to avoid a second database query, got ${service.databaseCalls} queries`, - ); - assert( - firstResponse !== secondResponse, - "expected Redis cache hit to return an equivalent payload, not the same object reference", - ); - assert( - firstResponse.data?.[0]?.timestamp instanceof Date, - "expected first response timestamps to be Date objects", - ); - assert( - secondResponse.data?.[0]?.timestamp instanceof Date, - "expected Redis response timestamps to be Date objects after hydration", - ); - - await new Promise((resolve) => setTimeout(resolve, 5100)); - - const thirdResponse = await service.getLatestPrices(); - - assert(thirdResponse.success, "third latest-prices response should succeed"); - assert( - service.databaseCalls === 2, - `expected TTL expiration to trigger a refresh, got ${service.databaseCalls} queries`, - ); - - service.clearCache(); - const fourthResponse = await service.getLatestPrices(); - - assert( - fourthResponse.success, - "fourth latest-prices response should succeed after manual cache clear", - ); - assert( - service.databaseCalls === 3, - `expected clearCache to force a fresh query, got ${service.databaseCalls} queries`, - ); - - console.log("responseCaching.test.ts passed"); -} - -run().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/test/retryUtil.test.ts b/test/retryUtil.test.ts index 204d57cc..89c8fbc6 100644 --- a/test/retryUtil.test.ts +++ b/test/retryUtil.test.ts @@ -17,9 +17,13 @@ async function testRetryUtil() { callCount++; return { data: "success" }; }, - { maxRetries: 3, retryDelay: 100 } + { maxRetries: 3, retryDelay: 100 }, + ); + assert.strictEqual( + callCount, + 1, + "Should only call once for successful request", ); - assert.strictEqual(callCount, 1, "Should only call once for successful request"); assert.deepStrictEqual(result, { data: "success" }); console.log("✅ PASS\n"); } catch (error) { @@ -42,7 +46,7 @@ async function testRetryUtil() { } return { data: "success after retries" }; }, - { maxRetries: 3, retryDelay: 100 } + { maxRetries: 3, retryDelay: 100 }, ); assert.strictEqual(callCount, 3, "Should retry until success"); assert.deepStrictEqual(result, { data: "success after retries" }); @@ -65,7 +69,7 @@ async function testRetryUtil() { error.config = {} as any; throw error; }, - { maxRetries: 3, retryDelay: 100 } + { maxRetries: 3, retryDelay: 100 }, ); console.error("❌ FAIL: Should have thrown error"); process.exit(1); @@ -100,7 +104,7 @@ async function testRetryUtil() { } return { data: "recovered" }; }, - { maxRetries: 3, retryDelay: 100 } + { maxRetries: 3, retryDelay: 100 }, ); assert.strictEqual(callCount, 2, "Should retry once on 503"); assert.deepStrictEqual(result, { data: "recovered" }); @@ -130,7 +134,7 @@ async function testRetryUtil() { }; throw error; }, - { maxRetries: 3, retryDelay: 100 } + { maxRetries: 3, retryDelay: 100 }, ); console.error("❌ FAIL: Should have thrown error"); process.exit(1); @@ -169,7 +173,7 @@ async function testRetryUtil() { maxRetries: 3, retryDelay: 100, shouldRetry: (error) => error.response?.status === 418, - } + }, ); assert.strictEqual(callCount, 2, "Should retry on custom condition"); assert.deepStrictEqual(result, { data: "custom retry worked" }); @@ -202,9 +206,13 @@ async function testRetryUtil() { retryCallbackCount++; assert.strictEqual(delay, 100, "Delay should be 100ms"); }, - } + }, + ); + assert.strictEqual( + retryCallbackCount, + 2, + "Callback should be called for each retry", ); - assert.strictEqual(retryCallbackCount, 2, "Callback should be called for each retry"); console.log("✅ PASS\n"); } catch (error) { console.error("❌ FAIL:", error); diff --git a/test/security.test.ts b/test/security.test.ts index 419aba83..2312e45d 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -8,64 +8,64 @@ const URL = `http://localhost:${PORT}/api`; const KEY = process.env.API_KEY; async function testSecurity() { - console.log("🛡️ Testing API Security...\n"); + console.log("🛡️ Testing API Security...\n"); - if (!KEY) { - console.error("❌ KEY not found in .env. Skip tests."); - return; - } + if (!KEY) { + console.error("❌ KEY not found in .env. Skip tests."); + return; + } - // 1. Missing key - console.log("Testing with missing key..."); - try { - await axios.get(`${URL}/market-rates/currencies`); - console.error("FAIL: Allowed without key"); - } catch (err: any) { - if (err.response?.status === 401) { - console.log("✅ PASS: Blocked (401)"); - } else { - console.error("FAIL: Wrong error", err.response?.status); - } + // 1. Missing key + console.log("Testing with missing key..."); + try { + await axios.get(`${URL}/market-rates/currencies`); + console.error("FAIL: Allowed without key"); + } catch (err: any) { + if (err.response?.status === 401) { + console.log("✅ PASS: Blocked (401)"); + } else { + console.error("FAIL: Wrong error", err.response?.status); } + } - // 2. Wrong key - console.log("\nTesting with wrong key..."); - try { - await axios.get(`${URL}/market-rates/currencies`, { - headers: { "X-API-KEY": "wrong" }, - }); - console.error("FAIL: Allowed with wrong key"); - } catch (err: any) { - if (err.response?.status === 401) { - console.log("✅ PASS: Blocked (401)"); - } else { - console.error("FAIL: Wrong error", err.response?.status); - } + // 2. Wrong key + console.log("\nTesting with wrong key..."); + try { + await axios.get(`${URL}/market-rates/currencies`, { + headers: { "X-API-KEY": "wrong" }, + }); + console.error("FAIL: Allowed with wrong key"); + } catch (err: any) { + if (err.response?.status === 401) { + console.log("✅ PASS: Blocked (401)"); + } else { + console.error("FAIL: Wrong error", err.response?.status); } + } - // 3. Correct key - console.log("\nTesting with correct key..."); - try { - const res = await axios.get(`${URL}/market-rates/currencies`, { - headers: { "X-API-KEY": KEY }, - }); - console.log("✅ PASS: Allowed (200)"); - } catch (err: any) { - console.error("FAIL: Blocked", err.message); - } + // 3. Correct key + console.log("\nTesting with correct key..."); + try { + const res = await axios.get(`${URL}/market-rates/currencies`, { + headers: { "X-API-KEY": KEY }, + }); + console.log("✅ PASS: Allowed (200)"); + } catch (err: any) { + console.error("FAIL: Blocked", err.message); + } - // 4. Docs protection check - console.log("\nTesting docs protection..."); - try { - await axios.get(`${URL}/docs`); - console.error("FAIL: Docs allowed without key"); - } catch (err: any) { - if (err.response?.status === 401) { - console.log("✅ PASS: Docs also protected (401)"); - } else { - console.error("FAIL: Docs wrong error", err.response?.status); - } + // 4. Docs protection check + console.log("\nTesting docs protection..."); + try { + await axios.get(`${URL}/docs`); + console.error("FAIL: Docs allowed without key"); + } catch (err: any) { + if (err.response?.status === 401) { + console.log("✅ PASS: Docs also protected (401)"); + } else { + console.error("FAIL: Docs wrong error", err.response?.status); } + } } testSecurity().catch(console.error); diff --git a/test/sorobanEventListener.test.ts b/test/sorobanEventListener.test.ts index 2f8f8cb3..37b1a302 100644 --- a/test/sorobanEventListener.test.ts +++ b/test/sorobanEventListener.test.ts @@ -1,4 +1,7 @@ -import { SorobanEventListener, ConfirmedPrice } from '../src/services/sorobanEventListener'; +import { + SorobanEventListener, + ConfirmedPrice, +} from "../src/services/sorobanEventListener"; let passed = 0; let failed = 0; @@ -13,10 +16,10 @@ function assert(description: string, condition: boolean) { } } -console.log('🧪 Testing SorobanEventListener...\n'); +console.log("🧪 Testing SorobanEventListener...\n"); // Test 1: Constructor throws without secret key -console.log('Constructor validation:'); +console.log("Constructor validation:"); const originalOracleKey = process.env.ORACLE_SECRET_KEY; const originalSorobanKey = process.env.SOROBAN_ADMIN_SECRET; @@ -29,48 +32,58 @@ try { } catch (e) { threwError = true; assert( - 'throws error when no secret key is configured', - e instanceof Error && e.message.includes('not found in environment variables') + "throws error when no secret key is configured", + e instanceof Error && + e.message.includes("not found in environment variables"), ); } -assert('constructor throws without keys', threwError); +assert("constructor throws without keys", threwError); // Restore keys for further tests process.env.ORACLE_SECRET_KEY = originalOracleKey; process.env.SOROBAN_ADMIN_SECRET = originalSorobanKey; // Test 2: Valid instantiation with secret key -console.log('\nInstantiation with valid key:'); +console.log("\nInstantiation with valid key:"); if (originalOracleKey || originalSorobanKey) { try { const listener = new SorobanEventListener(); - assert('creates instance with valid key', listener !== null); - assert('isActive returns false initially', listener.isActive() === false); - assert('getOraclePublicKey returns a string', typeof listener.getOraclePublicKey() === 'string'); - assert('public key starts with G', listener.getOraclePublicKey().startsWith('G')); + assert("creates instance with valid key", listener !== null); + assert("isActive returns false initially", listener.isActive() === false); + assert( + "getOraclePublicKey returns a string", + typeof listener.getOraclePublicKey() === "string", + ); + assert( + "public key starts with G", + listener.getOraclePublicKey().startsWith("G"), + ); } catch (e) { console.log(` ⚠ Skipped: ${e instanceof Error ? e.message : e}`); } } else { - console.log(' ⚠ Skipped: No secret key configured'); + console.log(" ⚠ Skipped: No secret key configured"); } // Test 3: ConfirmedPrice interface shape -console.log('\nConfirmedPrice interface:'); +console.log("\nConfirmedPrice interface:"); const mockPrice: ConfirmedPrice = { - currency: 'NGN', + currency: "NGN", rate: 1650.25, - txHash: 'abc123def456', - memoId: 'SF-NGN-1234567890-001', + txHash: "abc123def456", + memoId: "SF-NGN-1234567890-001", ledgerSeq: 12345, confirmedAt: new Date(), }; -assert('currency is string', typeof mockPrice.currency === 'string'); -assert('rate is number', typeof mockPrice.rate === 'number'); -assert('txHash is string', typeof mockPrice.txHash === 'string'); -assert('memoId can be string or null', mockPrice.memoId === null || typeof mockPrice.memoId === 'string'); -assert('ledgerSeq is number', typeof mockPrice.ledgerSeq === 'number'); -assert('confirmedAt is Date', mockPrice.confirmedAt instanceof Date); +assert("currency is string", typeof mockPrice.currency === "string"); +assert("rate is number", typeof mockPrice.rate === "number"); +assert("txHash is string", typeof mockPrice.txHash === "string"); +assert( + "memoId can be string or null", + mockPrice.memoId === null || typeof mockPrice.memoId === "string", +); +assert("ledgerSeq is number", typeof mockPrice.ledgerSeq === "number"); +assert("confirmedAt is Date", mockPrice.confirmedAt instanceof Date); console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); diff --git a/test/staleDetection.test.ts b/test/staleDetection.test.ts index 45daccf7..9efc676b 100644 --- a/test/staleDetection.test.ts +++ b/test/staleDetection.test.ts @@ -7,7 +7,7 @@ async function runTest() { try { const staleCurrencies = await intelligenceService.getStaleCurrencies(); console.log("Stale Currencies identified:", staleCurrencies); - + if (Array.isArray(staleCurrencies)) { console.log("✅ Test PASSED: Returned a list of currencies."); } else { diff --git a/test/stats.test.ts b/test/stats.test.ts index d9e053cb..d3f7f5c7 100644 --- a/test/stats.test.ts +++ b/test/stats.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Request, Response } from 'express'; -import statsRouter from '../src/routes/stats'; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Request, Response } from "express"; +import statsRouter from "../src/routes/stats"; // Mock Prisma const mockPrisma = { @@ -17,11 +17,11 @@ const mockPrisma = { }; // Mock the prisma module -vi.mock('../src/lib/prisma', () => ({ +vi.mock("../src/lib/prisma", () => ({ default: mockPrisma, })); -describe('GET /api/v1/stats/volume', () => { +describe("GET /api/v1/stats/volume", () => { let mockRequest: Partial; let mockResponse: Partial; @@ -36,17 +36,17 @@ describe('GET /api/v1/stats/volume', () => { vi.clearAllMocks(); }); - it('should return volume statistics for a given date', async () => { + it("should return volume statistics for a given date", async () => { // Mock data mockPrisma.priceHistory.count .mockResolvedValueOnce(150) // price history count - .mockResolvedValueOnce(5); // active currencies count - + .mockResolvedValueOnce(5); // active currencies count + mockPrisma.onChainPrice.count.mockResolvedValue(25); // on-chain price count - + mockPrisma.providerReputation.findMany.mockResolvedValue([ { - providerName: 'CoinGecko', + providerName: "CoinGecko", totalRequests: 1000, successfulRequests: 950, failedRequests: 50, @@ -54,7 +54,7 @@ describe('GET /api/v1/stats/volume', () => { lastFailure: null, }, { - providerName: 'ExchangeRateAPI', + providerName: "ExchangeRateAPI", totalRequests: 500, successfulRequests: 480, failedRequests: 20, @@ -65,22 +65,22 @@ describe('GET /api/v1/stats/volume', () => { mockPrisma.priceHistory.findMany .mockResolvedValueOnce([ - { currency: 'NGN' }, - { currency: 'KES' }, - { currency: 'GHS' }, - { currency: 'NGN' }, - { currency: 'KES' }, + { currency: "NGN" }, + { currency: "KES" }, + { currency: "GHS" }, + { currency: "NGN" }, + { currency: "KES" }, ]) .mockResolvedValueOnce([ - { source: 'CoinGecko' }, - { source: 'ExchangeRateAPI' }, + { source: "CoinGecko" }, + { source: "ExchangeRateAPI" }, ]); - mockRequest.query = { date: '2024-01-15' }; + mockRequest.query = { date: "2024-01-15" }; // Get the volume handler const volumeHandler = statsRouter.stack.find( - (layer: any) => layer.route?.path === '/volume' + (layer: any) => layer.route?.path === "/volume", )?.route?.stack[0]?.handle; expect(volumeHandler).toBeDefined(); @@ -90,7 +90,7 @@ describe('GET /api/v1/stats/volume', () => { expect(mockResponse.json).toHaveBeenCalledWith({ success: true, data: expect.objectContaining({ - date: '2024-01-15', + date: "2024-01-15", dataPoints: { priceHistoryEntries: 150, onChainConfirmations: 25, @@ -100,35 +100,35 @@ describe('GET /api/v1/stats/volume', () => { total: 1500, successful: 1430, failed: 70, - successRate: '95.33%', + successRate: "95.33%", }, activity: { activeCurrencies: 3, // NGN, KES, GHS (unique) activeDataSources: 2, // CoinGecko, ExchangeRateAPI - currencies: ['NGN', 'KES', 'GHS'], - sources: ['CoinGecko', 'ExchangeRateAPI'], + currencies: ["NGN", "KES", "GHS"], + sources: ["CoinGecko", "ExchangeRateAPI"], }, providers: expect.arrayContaining([ expect.objectContaining({ - name: 'CoinGecko', + name: "CoinGecko", totalRequests: 1000, - successRate: '95.00%', + successRate: "95.00%", }), expect.objectContaining({ - name: 'ExchangeRateAPI', + name: "ExchangeRateAPI", totalRequests: 500, - successRate: '96.00%', + successRate: "96.00%", }), ]), }), }); }); - it('should handle invalid date format', async () => { - mockRequest.query = { date: 'invalid-date' }; + it("should handle invalid date format", async () => { + mockRequest.query = { date: "invalid-date" }; const volumeHandler = statsRouter.stack.find( - (layer: any) => layer.route?.path === '/volume' + (layer: any) => layer.route?.path === "/volume", )?.route?.stack[0]?.handle; await volumeHandler(mockRequest as Request, mockResponse as Response); @@ -140,14 +140,14 @@ describe('GET /api/v1/stats/volume', () => { }); }); - it('should default to today when no date is provided', async () => { + it("should default to today when no date is provided", async () => { mockPrisma.priceHistory.count.mockResolvedValue(0); mockPrisma.onChainPrice.count.mockResolvedValue(0); mockPrisma.providerReputation.findMany.mockResolvedValue([]); mockPrisma.priceHistory.findMany.mockResolvedValue([]); const volumeHandler = statsRouter.stack.find( - (layer: any) => layer.route?.path === '/volume' + (layer: any) => layer.route?.path === "/volume", )?.route?.stack[0]?.handle; await volumeHandler(mockRequest as Request, mockResponse as Response); diff --git a/test/stroops.test.ts b/test/stroops.test.ts index 34d764aa..ceb26f00 100644 --- a/test/stroops.test.ts +++ b/test/stroops.test.ts @@ -1,4 +1,4 @@ -import { toStroops } from '../src/utils/stroops'; +import { toStroops } from "../src/utils/stroops"; let passed = 0; let failed = 0; @@ -13,15 +13,19 @@ function assert(description: string, actual: number, expected: number) { } } -console.log('🧪 Testing toStroops...\n'); +console.log("🧪 Testing toStroops...\n"); -assert('normalize(1.5) returns 15000000', toStroops(1.5), 15_000_000); -assert('normalize(1) returns 10000000', toStroops(1), 10_000_000); -assert('normalize(0) returns 0', toStroops(0), 0); -assert('normalize(0.25) returns 2500000', toStroops(0.25), 2_500_000); -assert('normalize("1.5") returns 15000000', toStroops("1.5"), 15_000_000); -assert('normalize("0.1") returns 1000000', toStroops("0.1"), 1_000_000); -assert('normalize(10000000) integer passthrough', toStroops(10_000_000), 100_000_000_000_000); +assert("normalize(1.5) returns 15000000", toStroops(1.5), 15_000_000); +assert("normalize(1) returns 10000000", toStroops(1), 10_000_000); +assert("normalize(0) returns 0", toStroops(0), 0); +assert("normalize(0.25) returns 2500000", toStroops(0.25), 2_500_000); +assert('normalize("1.5") returns 15000000', toStroops("1.5"), 15_000_000); +assert('normalize("0.1") returns 1000000', toStroops("0.1"), 1_000_000); +assert( + "normalize(10000000) integer passthrough", + toStroops(10_000_000), + 100_000_000_000_000, +); console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1);