diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 3338a0f4..54fb3c61 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -82,10 +82,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: npm - cache-dependency-path: | - api/package-lock.json - oracle/package-lock.json - name: Install all workspace dependencies from root run: npm install --workspaces @@ -122,8 +118,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - cache: npm - cache-dependency-path: api/package-lock.json - name: Install dependencies run: npm ci @@ -171,8 +165,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - cache: npm - cache-dependency-path: oracle/package-lock.json - name: Install dependencies run: npm ci diff --git a/api/src/__tests__/integration.test.ts b/api/src/__tests__/integration.test.ts index 6c1562c3..91ce52fd 100644 --- a/api/src/__tests__/integration.test.ts +++ b/api/src/__tests__/integration.test.ts @@ -100,9 +100,7 @@ describe('Complete Deposit Flow', () => { }); it('submit calls monitorTransaction after successful submitTransaction', async () => { - await request(app) - .post('/api/lending/submit') - .send({ signedXdr: 'signed_xdr_payload' }); + await request(app).post('/api/lending/submit').send({ signedXdr: 'signed_xdr_payload' }); expect(mockStellarService.submitTransaction).toHaveBeenCalledWith('signed_xdr_payload'); expect(mockStellarService.monitorTransaction).toHaveBeenCalledWith('abc123txhash'); @@ -201,9 +199,7 @@ describe('Error Handling', () => { error: 'tx_bad_seq', }); - const res = await request(app) - .post('/api/lending/submit') - .send({ signedXdr: 'bad_xdr' }); + const res = await request(app).post('/api/lending/submit').send({ signedXdr: 'bad_xdr' }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); diff --git a/api/src/__tests__/lending.controller.test.ts b/api/src/__tests__/lending.controller.test.ts index 5275e1ea..154eda61 100644 --- a/api/src/__tests__/lending.controller.test.ts +++ b/api/src/__tests__/lending.controller.test.ts @@ -19,7 +19,6 @@ afterEach(() => { jest.clearAllMocks(); }); - // Mock StellarService before importing app import { StellarService } from '../services/stellar.service'; jest.mock('../services/stellar.service'); @@ -45,20 +44,20 @@ const mockStellarService: jest.Mocked = { import request from 'supertest'; import app from '../app'; - describe('Lending Controller', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('GET /api/lending/prepare/:operation', () => { - const validBody = { - userAddress: 'GDZZJ3UPZZCKY5DBH6ZGMPMRORRBG4ECIORASBUAXPPNCL4SYRHNLYU2', - amount: '1000000', + const validBody = { + userAddress: 'GDZZJ3UPZZCKY5DBH6ZGMPMRORRBG4ECIORASBUAXPPNCL4SYRHNLYU2', + amount: '1000000', }; - it.each(['deposit', 'borrow', 'repay', 'withdraw']) - ('should return unsigned XDR for %s', async (operation) => { + it.each(['deposit', 'borrow', 'repay', 'withdraw'])( + 'should return unsigned XDR for %s', + async (operation) => { const response = await request(app) .get(`/api/lending/prepare/${operation}`) .send(validBody); @@ -67,7 +66,8 @@ describe('Lending Controller', () => { expect(response.body.unsignedXdr).toBe('unsigned_xdr_string'); expect(response.body.operation).toBe(operation); expect(response.body.expiresAt).toBeDefined(); - }); + } + ); it('should return 400 for invalid operation', async () => { const response = await request(app).get('/api/lending/prepare/invalid_op').send(validBody); diff --git a/api/src/__tests__/stellar.service.test.ts b/api/src/__tests__/stellar.service.test.ts index 1d731637..71d59984 100644 --- a/api/src/__tests__/stellar.service.test.ts +++ b/api/src/__tests__/stellar.service.test.ts @@ -202,9 +202,7 @@ describe('StellarService', () => { expect(account).toBeDefined(); expect(mockedAxios.get).toHaveBeenCalledWith( - expect.stringContaining( - '/accounts/GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' - ) + expect.stringContaining('/accounts/GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX') ); }); @@ -255,7 +253,11 @@ describe('StellarService', () => { mockedAxios.post.mockImplementation(() => { callCount++; if (callCount < 3) { - return mockAxiosReject({ status: 502, data: { detail: 'Bad gateway' }, message: 'Bad gateway' }); + return mockAxiosReject({ + status: 502, + data: { detail: 'Bad gateway' }, + message: 'Bad gateway', + }); } return Promise.resolve({ data: { hash: 'tx_hash_abc', ledger: 777, successful: true }, @@ -289,7 +291,11 @@ describe('StellarService', () => { it('stops after max retries on persistent 5xx errors and returns failure', async () => { jest.useFakeTimers(); mockedAxios.post.mockImplementation(() => - mockAxiosReject({ status: 503, data: { detail: 'Service Unavailable' }, message: 'Service Unavailable' }) + mockAxiosReject({ + status: 503, + data: { detail: 'Service Unavailable' }, + message: 'Service Unavailable', + }) ); const promise = service.submitTransaction('mock_tx_xdr'); @@ -531,4 +537,4 @@ describe('StellarService', () => { } ); }); -}); \ No newline at end of file +}); diff --git a/api/src/services/stellar.service.ts b/api/src/services/stellar.service.ts index 1479a6be..c9be8e66 100644 --- a/api/src/services/stellar.service.ts +++ b/api/src/services/stellar.service.ts @@ -84,12 +84,7 @@ export class StellarService { async submitTransaction(txXdr: string): Promise { const { - request: { - maxRetries, - retryInitialDelayMs, - retryMaxDelayMs, - timeout, - }, + request: { maxRetries, retryInitialDelayMs, retryMaxDelayMs, timeout }, } = config; for (let attempt = 0; attempt <= maxRetries; attempt++) { @@ -136,10 +131,7 @@ export class StellarService { } // Exponential backoff with cap - const backoff = Math.min( - retryInitialDelayMs * Math.pow(2, attempt), - retryMaxDelayMs - ); + const backoff = Math.min(retryInitialDelayMs * Math.pow(2, attempt), retryMaxDelayMs); logger.warn( `Submit transaction attempt ${attempt + 1} failed${ status ? ` (status ${status})` : '' diff --git a/oracle/src/providers/base-provider.ts b/oracle/src/providers/base-provider.ts index 03f53d4c..f4c9ef6c 100644 --- a/oracle/src/providers/base-provider.ts +++ b/oracle/src/providers/base-provider.ts @@ -27,6 +27,7 @@ export abstract class BasePriceProvider { protected lastRequestTime: number = 0; protected requestCount: number = 0; protected windowStartTime: number = Date.now(); + private rateLimitChain: Promise = Promise.resolve(); constructor(config: ProviderConfig) { this.config = config; @@ -116,6 +117,14 @@ export abstract class BasePriceProvider { * Enforce rate limiting */ protected async enforceRateLimit(): Promise { + // Serialize rate-limit state updates so concurrent requests cannot + // all pass the same counter check in parallel. + const run = this.rateLimitChain.then(() => this.enforceRateLimitInternal()); + this.rateLimitChain = run.catch(() => undefined); + await run; + } + + private async enforceRateLimitInternal(): Promise { const now = Date.now(); const { maxRequests, windowMs } = this.config.rateLimit; diff --git a/oracle/src/services/cache.ts b/oracle/src/services/cache.ts index 41a01b2d..e2d4561a 100644 --- a/oracle/src/services/cache.ts +++ b/oracle/src/services/cache.ts @@ -177,7 +177,7 @@ export class Cache { private evictLRUBatch(): void { const batchSize = Math.max( 1, - Math.ceil(this.config.maxEntries * this.config.evictBatchFraction), + Math.ceil(this.config.maxEntries * this.config.evictBatchFraction) ); let evicted = 0; diff --git a/oracle/src/services/circuit-breaker.ts b/oracle/src/services/circuit-breaker.ts index 7a9c67a3..144f5b4b 100644 --- a/oracle/src/services/circuit-breaker.ts +++ b/oracle/src/services/circuit-breaker.ts @@ -109,7 +109,9 @@ export class CircuitBreaker { this.consecutiveFailures = 0; if (this.state !== CircuitState.CLOSED) { - logger.info(`Circuit breaker CLOSED for provider "${this.config.providerName}" – recovery confirmed`); + logger.info( + `Circuit breaker CLOSED for provider "${this.config.providerName}" – recovery confirmed` + ); this.transitionTo(CircuitState.CLOSED); } } diff --git a/oracle/tests/circuit-breaker-aggregator.test.ts b/oracle/tests/circuit-breaker-aggregator.test.ts index c49cb63e..4b793963 100644 --- a/oracle/tests/circuit-breaker-aggregator.test.ts +++ b/oracle/tests/circuit-breaker-aggregator.test.ts @@ -33,10 +33,17 @@ class MockProvider extends BasePriceProvider { if (this._fail) throw new Error(`${this.name} is down`); const price = this.prices.get(asset.toUpperCase()); if (price === undefined) throw new Error(`${asset} not found`); - return { asset: asset.toUpperCase(), price, timestamp: Math.floor(Date.now() / 1000), source: this.name }; + return { + asset: asset.toUpperCase(), + price, + timestamp: Math.floor(Date.now() / 1000), + source: this.name, + }; } - setFail(v: boolean) { this._fail = v; } + setFail(v: boolean) { + this._fail = v; + } } function makeAggregator(providers: MockProvider[], backoffMs = 10_000) { diff --git a/oracle/tests/circuit-breaker.test.ts b/oracle/tests/circuit-breaker.test.ts index 6dbba80c..98961118 100644 --- a/oracle/tests/circuit-breaker.test.ts +++ b/oracle/tests/circuit-breaker.test.ts @@ -11,7 +11,11 @@ */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { CircuitBreaker, CircuitState, createCircuitBreaker } from '../src/services/circuit-breaker.js'; +import { + CircuitBreaker, + CircuitState, + createCircuitBreaker, +} from '../src/services/circuit-breaker.js'; describe('CircuitBreaker', () => { let cb: CircuitBreaker; diff --git a/stellar-lend/contracts/lending/src/borrow.rs b/stellar-lend/contracts/lending/src/borrow.rs index 9eb0dca9..9c844fc9 100644 --- a/stellar-lend/contracts/lending/src/borrow.rs +++ b/stellar-lend/contracts/lending/src/borrow.rs @@ -136,7 +136,7 @@ pub fn borrow( } let mut debt_position = get_debt_position(env, &user, Some(&asset)); - let accrued_interest = calculate_interest(env, &debt_position); + let accrued_interest = calculate_interest(env, &debt_position)?; debt_position.borrowed_amount = debt_position .borrowed_amount @@ -227,7 +227,7 @@ pub fn repay(env: &Env, user: Address, asset: Address, amount: i128) -> Result<( } // First repay interest, then principal - let accrued_interest = calculate_interest(env, &debt_position); + let accrued_interest = calculate_interest(env, &debt_position)?; debt_position.interest_accrued = debt_position .interest_accrued .checked_add(accrued_interest) @@ -286,9 +286,9 @@ pub(crate) fn validate_collateral_ratio(collateral: i128, borrow: i128) -> Resul Ok(()) } -pub(crate) fn calculate_interest(env: &Env, position: &DebtPosition) -> i128 { +pub(crate) fn calculate_interest(env: &Env, position: &DebtPosition) -> Result { if position.borrowed_amount == 0 { - return 0; + return Ok(0); } let current_time = env.ledger().timestamp(); @@ -304,7 +304,7 @@ pub(crate) fn calculate_interest(env: &Env, position: &DebtPosition) -> i128 { .div(&I256::from_i128(env, 10000)) .div(&I256::from_i128(env, SECONDS_PER_YEAR as i128)); - interest_256.to_i128().unwrap_or(i128::MAX) + interest_256.to_i128().ok_or(BorrowError::Overflow) } fn get_debt_position(env: &Env, user: &Address, default_asset: Option<&Address>) -> DebtPosition { @@ -396,8 +396,9 @@ pub fn initialize_borrow_settings( pub fn get_user_debt(env: &Env, user: &Address) -> DebtPosition { let mut position = get_debt_position(env, user, None); - let accrued = calculate_interest(env, &position); - position.interest_accrued = position.interest_accrued.saturating_add(accrued); + if let Ok(accrued) = calculate_interest(env, &position) { + position.interest_accrued = position.interest_accrued.saturating_add(accrued); + } position } diff --git a/stellar-lend/contracts/lending/src/borrow_test.rs b/stellar-lend/contracts/lending/src/borrow_test.rs index e45e9e03..42f2e4bf 100644 --- a/stellar-lend/contracts/lending/src/borrow_test.rs +++ b/stellar-lend/contracts/lending/src/borrow_test.rs @@ -1,4 +1,5 @@ use super::*; +use crate::borrow::calculate_interest; use soroban_sdk::{ testutils::{Address as _, Ledger}, Address, Env, @@ -149,6 +150,40 @@ fn test_borrow_interest_accrual() { assert!(debt.interest_accrued <= 5000); // ~5% of 100,000 } +#[test] +fn test_interest_overflow_returns_error() { + let env = Env::default(); + env.mock_all_auths(); + + // Construct a position that will produce an interest larger than i128 when scaled + let mut position = DebtPosition { + borrowed_amount: i128::MAX, + interest_accrued: 0, + last_update: 0, + asset: Address::generate(&env), + }; + + // Advance time by 100 years to amplify interest (roughly borrowed * 5x at 5% APY) + env.ledger().with_mut(|li| { + li.timestamp = 100 * 31_536_000; + }); + + // Borrowed amount is i128::MAX, 100y at 5% should overflow i128 + let result = calculate_interest(&env, &position); + assert!(matches!(result, Err(BorrowError::Overflow))); + + // Ensure callers can propagate the error; simulate accrue step + position.last_update = 0; + let accrue_result = (|| -> Result<(), BorrowError> { + let new_interest = calculate_interest(&env, &position)?; + position.interest_accrued = position + .interest_accrued + .checked_add(new_interest) + .ok_or(BorrowError::Overflow)?; + Ok(()) + })(); + assert!(matches!(accrue_result, Err(BorrowError::Overflow))); +} #[test] fn test_collateral_ratio_validation() { let env = Env::default(); diff --git a/stellar-lend/contracts/lending/src/math_safety_test.rs b/stellar-lend/contracts/lending/src/math_safety_test.rs index 142d0c15..f188a212 100644 --- a/stellar-lend/contracts/lending/src/math_safety_test.rs +++ b/stellar-lend/contracts/lending/src/math_safety_test.rs @@ -16,11 +16,11 @@ fn test_interest_calculation_extreme_values() { asset: Address::generate(&env), }; - // Set ledger time to far future (100 years from now) - env.ledger().with_mut(|li| li.timestamp = 100 * 31536000); + // Set ledger time to 1 year from now to keep result within i128 bounds + env.ledger().with_mut(|li| li.timestamp = 31_536_000); // calculate_interest uses I256 intermediate, so it handles large results - let interest = calculate_interest(&env, &position); + let interest = calculate_interest(&env, &position).unwrap_or(0); assert!(interest > 0); // Test with large amount (10^30) and 3 years (approx 10^8 seconds) @@ -34,7 +34,7 @@ fn test_interest_calculation_extreme_values() { }; env.ledger().with_mut(|li| li.timestamp = 3 * 31536000); - let large_interest = calculate_interest(&env, &large_position); + let large_interest = calculate_interest(&env, &large_position).unwrap_or(0); // 10^30 * 0.05 * 3 = 1.5 * 10^29 assert!(large_interest > 100_000_000_000_000_000_000_000_000_000i128); // > 10^29 assert!(large_interest < 200_000_000_000_000_000_000_000_000_000i128); // < 2*10^29