Skip to content
Merged
8 changes: 0 additions & 8 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 2 additions & 6 deletions api/src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
submitTransaction: jest.fn(),
monitorTransaction: jest.fn(),
healthCheck: jest.fn(),
} as any;

Check warning on line 21 in api/src/__tests__/integration.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (18)

Unexpected any. Specify a different type

Check warning on line 21 in api/src/__tests__/integration.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (20)

Unexpected any. Specify a different type

beforeAll(() => {
(StellarService as jest.Mock).mockImplementation(() => mockStellarService);
Expand Down Expand Up @@ -100,9 +100,7 @@
});

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');
Expand Down Expand Up @@ -201,9 +199,7 @@
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);
Expand Down
16 changes: 8 additions & 8 deletions api/src/__tests__/lending.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
jest.clearAllMocks();
});


// Mock StellarService before importing app
import { StellarService } from '../services/stellar.service';
jest.mock('../services/stellar.service');
Expand All @@ -40,25 +39,25 @@
horizon: true,
sorobanRpc: true,
}),
} as any;

Check warning on line 42 in api/src/__tests__/lending.controller.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (18)

Unexpected any. Specify a different type

Check warning on line 42 in api/src/__tests__/lending.controller.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (20)

Unexpected any. Specify a different type
(StellarService as jest.Mock).mockImplementation(() => mockStellarService);
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);
Expand All @@ -67,7 +66,8 @@
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);
Expand Down
18 changes: 12 additions & 6 deletions api/src/__tests__/stellar.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@
message = 'Mocked network error',
}: {
status?: number;
data?: any;

Check warning on line 18 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (18)

Unexpected any. Specify a different type

Check warning on line 18 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (20)

Unexpected any. Specify a different type
message?: string;
} = {}) {
const error = new Error(message) as any;

Check warning on line 21 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (18)

Unexpected any. Specify a different type

Check warning on line 21 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (20)

Unexpected any. Specify a different type
error.response = { status, data };
return Promise.reject(error);
}

// Default catch-all implementations — these resolve successfully so that
// tests which don't override axios still pass without leaking rejections.
const defaultAxiosGet = (url: string, _config?: any) => {

Check warning on line 28 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (18)

Unexpected any. Specify a different type

Check warning on line 28 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (20)

Unexpected any. Specify a different type
if (url?.includes('/accounts/')) {
return Promise.resolve({
data: {
Expand All @@ -38,7 +38,7 @@
statusText: 'OK',
headers: {},
config: { url },
} as any);

Check warning on line 41 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (18)

Unexpected any. Specify a different type

Check warning on line 41 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (20)

Unexpected any. Specify a different type
}
if (url?.includes('/transactions/')) {
return Promise.resolve({
Expand All @@ -47,7 +47,7 @@
statusText: 'OK',
headers: {},
config: { url },
} as any);

Check warning on line 50 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (18)

Unexpected any. Specify a different type

Check warning on line 50 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (20)

Unexpected any. Specify a different type
}
return Promise.resolve({
data: {},
Expand All @@ -55,10 +55,10 @@
statusText: 'OK',
headers: {},
config: { url: url ?? '' },
} as any);

Check warning on line 58 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (18)

Unexpected any. Specify a different type

Check warning on line 58 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (20)

Unexpected any. Specify a different type
};

const defaultAxiosPost = (url: string, _data?: any, _config?: any) =>

Check warning on line 61 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (18)

Unexpected any. Specify a different type

Check warning on line 61 in api/src/__tests__/stellar.service.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (20)

Unexpected any. Specify a different type
Promise.resolve({
data: { hash: 'tx_hash_123', ledger: 12345, successful: true },
status: 200,
Expand Down Expand Up @@ -202,9 +202,7 @@

expect(account).toBeDefined();
expect(mockedAxios.get).toHaveBeenCalledWith(
expect.stringContaining(
'/accounts/GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
)
expect.stringContaining('/accounts/GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX')
);
});

Expand Down Expand Up @@ -255,7 +253,11 @@
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 },
Expand Down Expand Up @@ -289,7 +291,11 @@
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');
Expand Down Expand Up @@ -531,4 +537,4 @@
}
);
});
});
});
12 changes: 2 additions & 10 deletions api/src/services/stellar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,7 @@ export class StellarService {

async submitTransaction(txXdr: string): Promise<TransactionResponse> {
const {
request: {
maxRetries,
retryInitialDelayMs,
retryMaxDelayMs,
timeout,
},
request: { maxRetries, retryInitialDelayMs, retryMaxDelayMs, timeout },
} = config;

for (let attempt = 0; attempt <= maxRetries; attempt++) {
Expand Down Expand Up @@ -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})` : ''
Expand Down
9 changes: 9 additions & 0 deletions oracle/src/providers/base-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export abstract class BasePriceProvider {
protected lastRequestTime: number = 0;
protected requestCount: number = 0;
protected windowStartTime: number = Date.now();
private rateLimitChain: Promise<void> = Promise.resolve();

constructor(config: ProviderConfig) {
this.config = config;
Expand Down Expand Up @@ -116,6 +117,14 @@ export abstract class BasePriceProvider {
* Enforce rate limiting
*/
protected async enforceRateLimit(): Promise<void> {
// 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<void> {
const now = Date.now();
const { maxRequests, windowMs } = this.config.rateLimit;

Expand Down
2 changes: 1 addition & 1 deletion oracle/src/services/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion oracle/src/services/circuit-breaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
11 changes: 9 additions & 2 deletions oracle/tests/circuit-breaker-aggregator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion oracle/tests/circuit-breaker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 8 additions & 7 deletions stellar-lend/contracts/lending/src/borrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<i128, BorrowError> {
if position.borrowed_amount == 0 {
return 0;
return Ok(0);
}

let current_time = env.ledger().timestamp();
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
35 changes: 35 additions & 0 deletions stellar-lend/contracts/lending/src/borrow_test.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::*;
use crate::borrow::calculate_interest;
use soroban_sdk::{
testutils::{Address as _, Ledger},
Address, Env,
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 4 additions & 4 deletions stellar-lend/contracts/lending/src/math_safety_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading