Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion oracle/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { loadConfig, getSafeConfig, type OracleServiceConfig } from './config.js';
import { configureLogger, logger } from './utils/logger.js';
import { configureLogger, logger, logProviderHealth, logStalenessAlert } from './utils/logger.js';

Check warning on line 10 in oracle/src/index.ts

View workflow job for this annotation

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

'logProviderHealth' is defined but never used

Check warning on line 10 in oracle/src/index.ts

View workflow job for this annotation

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

'logProviderHealth' is defined but never used
import {
createCoinGeckoProvider,
createBinanceProvider,
Expand Down Expand Up @@ -37,6 +37,7 @@
private contractUpdater: ContractUpdater;
private intervalId?: ReturnType<typeof setInterval>;
private isRunning: boolean = false;
private lastSuccessfulUpdate: number | null = null;

constructor(config: OracleServiceConfig) {
// Store config but never log adminSecretKey directly
Expand Down Expand Up @@ -133,6 +134,16 @@

logger.info('Starting price update cycle', { assets });

// Check for staleness
if (this.lastSuccessfulUpdate) {
const ageSeconds = (Date.now() - this.lastSuccessfulUpdate) / 1000;
const thresholdSeconds = this.config.priceStaleThresholdSeconds;

if (ageSeconds > thresholdSeconds) {
logStalenessAlert(ageSeconds, thresholdSeconds, this.lastSuccessfulUpdate);
}
}

try {
// Fetch aggregated prices
const prices = await this.aggregator.getPrices(assets);
Expand Down Expand Up @@ -160,6 +171,10 @@
durationMs: Date.now() - startTime,
});

if (successful.length > 0) {
this.lastSuccessfulUpdate = Date.now();
}

if (failed.length > 0) {
logger.warn('Some price updates failed', {
failedAssets: failed.map((f) => f.asset),
Expand Down
15 changes: 15 additions & 0 deletions oracle/src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,18 @@ export function logProviderHealth(
logger.warn('Provider unhealthy', logData);
}
}
/**
* Log Oracle price staleness alert
*/
export function logStalenessAlert(
ageSeconds: number,
thresholdSeconds: number,
lastUpdateTime?: number
) {
logger.warn('Oracle price staleness detected', {
ageSeconds,
thresholdSeconds,
lastUpdateTime: lastUpdateTime ? new Date(lastUpdateTime).toISOString() : 'never',
alertType: 'staleness_monitor',
});
}
138 changes: 138 additions & 0 deletions oracle/tests/staleness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Tests for Oracle Price Staleness Detection
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { OracleService } from '../src/index.js';
import { logger, logStalenessAlert } from '../src/utils/logger.js';

// Mock logger to verify calls
vi.mock('../src/utils/logger.js', async () => {
const actual = await vi.importActual('../src/utils/logger.js');
return {
...actual,
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
logStalenessAlert: vi.fn(),
};
});

// Mock contract updater
vi.mock('../src/services/contract-updater.js', () => ({
createContractUpdater: vi.fn(() => ({
updatePrices: vi.fn().mockResolvedValue([{ success: true, asset: 'XLM', price: 100n, timestamp: Date.now() }]),
healthCheck: vi.fn().mockResolvedValue(true),
getAdminPublicKey: vi.fn().mockReturnValue('GTEST123'),
})),
ContractUpdater: vi.fn(),
}));

// Mock aggregator
vi.mock('../src/services/price-aggregator.js', () => ({
createAggregator: vi.fn(() => ({
getPrices: vi.fn().mockResolvedValue(new Map([['XLM', { asset: 'XLM', price: 100n, timestamp: Date.now() }]])),
getPrice: vi.fn(),
getProviders: vi.fn().mockReturnValue([]),
getStats: vi.fn().mockReturnValue({}),
})),
}));

describe('Oracle Price Staleness Detection', () => {
let service: OracleService;
const STALE_THRESHOLD = 300; // 5 minutes

const mockConfig: any = {
stellarNetwork: 'testnet',
stellarRpcUrl: 'http://localhost:8000',
contractId: 'CTEST123',
adminSecretKey: 'S123',
updateIntervalMs: 60000,
maxPriceDeviationPercent: 10,
priceStaleThresholdSeconds: STALE_THRESHOLD,
cacheTtlSeconds: 30,
logLevel: 'info',
providers: [],
};

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-24T12:00:00Z'));
service = new OracleService(mockConfig);
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

it('should not log staleness alert on first update', async () => {
await service.updatePrices(['XLM']);
expect(logStalenessAlert).not.toHaveBeenCalled();
});

it('should not log staleness alert if update happens within threshold', async () => {
// First successful update
await service.updatePrices(['XLM']);

// Advance time by 4 minutes (less than 5m threshold)
vi.advanceTimersByTime(4 * 60 * 1000);

await service.updatePrices(['XLM']);
expect(logStalenessAlert).not.toHaveBeenCalled();
});

it('should log staleness alert if update age exceeds threshold', async () => {
// First successful update
await service.updatePrices(['XLM']);

// Advance time by 6 minutes (more than 5m threshold)
vi.advanceTimersByTime(6 * 60 * 1000);

await service.updatePrices(['XLM']);

expect(logStalenessAlert).toHaveBeenCalledWith(
expect.any(Number), // ageSeconds around 360
STALE_THRESHOLD,
expect.any(Number) // lastUpdateTime
);

const callArgs = vi.mocked(logStalenessAlert).mock.calls[0];
expect(callArgs[0]).toBe(360); // 6 minutes in seconds
});

it('should update lastSuccessfulUpdate after a successful cycle', async () => {
// First update
await service.updatePrices(['XLM']);

// Advance time by 4 minutes
vi.advanceTimersByTime(4 * 60 * 1000);
await service.updatePrices(['XLM']);

// Advance another 4 minutes (total 8 from start, but only 4 from last update)
vi.advanceTimersByTime(4 * 60 * 1000);
await service.updatePrices(['XLM']);

expect(logStalenessAlert).not.toHaveBeenCalled();
});

it('should log alert even if price fetching fails but cycle starts', async () => {
// First success
await service.updatePrices(['XLM']);

// Advance beyond threshold
vi.advanceTimersByTime(6 * 60 * 1000);

// Mock failure for the NEXT getPrices call
const { createAggregator } = await import('../src/services/price-aggregator.js');
vi.mocked(createAggregator().getPrices).mockRejectedValueOnce(new Error('API Down'));

await service.updatePrices(['XLM']);

// Alert should still be triggered because it's checked at the start of the cycle
expect(logStalenessAlert).toHaveBeenCalled();
});
});
3 changes: 3 additions & 0 deletions oracle/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"lib": [
"ES2022"
],
"types": [
"node"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
Expand Down
Loading