Skip to content

Commit 2d0b10b

Browse files
authored
Merge pull request #154 from rahimatonize/feature/issue-97-granular-pause-operations
Feature/issue 97 granular pause operations
2 parents 0f73010 + d2a8e83 commit 2d0b10b

8 files changed

Lines changed: 510 additions & 2 deletions

File tree

oracle/tests/memory-leak.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* Memory Leak Detection Test for Oracle Service
3+
*
4+
* This test runs the Oracle service update cycle many times
5+
* and monitors heap usage to ensure there are no linear memory leaks.
6+
*/
7+
8+
import { describe, it, expect, beforeEach, vi } from 'vitest';
9+
import { OracleService } from '../src/index.js';
10+
import type { OracleServiceConfig } from '../src/config.js';
11+
12+
// Mock contract updater to avoid actual blockchain calls
13+
vi.mock('../src/services/contract-updater.js', () => ({
14+
createContractUpdater: vi.fn(() => ({
15+
updatePrices: vi
16+
.fn()
17+
.mockResolvedValue([{ success: true, asset: 'XLM', price: 150000n, timestamp: Date.now() }]),
18+
healthCheck: vi.fn().mockResolvedValue(true),
19+
getAdminPublicKey: vi.fn().mockReturnValue('GTEST123'),
20+
})),
21+
ContractUpdater: vi.fn(),
22+
}));
23+
24+
// Mock providers to avoid actual API calls
25+
vi.mock('../src/providers/coingecko.js', () => ({
26+
createCoinGeckoProvider: vi.fn(() => ({
27+
name: 'coingecko',
28+
isEnabled: true,
29+
priority: 1,
30+
weight: 0.6,
31+
getSupportedAssets: () => ['XLM', 'BTC', 'ETH'],
32+
fetchPrice: vi.fn().mockResolvedValue({
33+
asset: 'XLM',
34+
price: 0.15,
35+
timestamp: Math.floor(Date.now() / 1000),
36+
source: 'coingecko',
37+
}),
38+
})),
39+
}));
40+
41+
vi.mock('../src/providers/binance.js', () => ({
42+
createBinanceProvider: vi.fn(() => ({
43+
name: 'binance',
44+
isEnabled: true,
45+
priority: 2,
46+
weight: 0.4,
47+
getSupportedAssets: () => ['XLM', 'BTC', 'ETH'],
48+
fetchPrice: vi.fn().mockResolvedValue({
49+
asset: 'XLM',
50+
price: 0.152,
51+
timestamp: Math.floor(Date.now() / 1000),
52+
source: 'binance',
53+
}),
54+
})),
55+
}));
56+
57+
describe('OracleService Memory Leak Detection', () => {
58+
let mockConfig: OracleServiceConfig;
59+
60+
beforeEach(() => {
61+
mockConfig = {
62+
stellarNetwork: 'testnet',
63+
stellarRpcUrl: 'https://soroban-testnet.stellar.org',
64+
contractId: 'CTEST123',
65+
adminSecretKey: 'STEST123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
66+
updateIntervalMs: 1000,
67+
maxPriceDeviationPercent: 10,
68+
priceStaleThresholdSeconds: 300,
69+
cacheTtlSeconds: 30,
70+
logLevel: 'error',
71+
providers: [
72+
{
73+
name: 'coingecko',
74+
enabled: true,
75+
priority: 1,
76+
weight: 0.6,
77+
baseUrl: 'https://api.coingecko.com/api/v3',
78+
rateLimit: { maxRequests: 10, windowMs: 60000 },
79+
},
80+
{
81+
name: 'binance',
82+
enabled: true,
83+
priority: 2,
84+
weight: 0.4,
85+
baseUrl: 'https://api.binance.com/api/v3',
86+
rateLimit: { maxRequests: 1200, windowMs: 60000 },
87+
},
88+
],
89+
};
90+
});
91+
92+
it('should maintain stable memory usage over 2000 update cycles', async () => {
93+
const service = new OracleService(mockConfig);
94+
const iterations = 2000;
95+
const assets = ['XLM', 'BTC', 'ETH', 'SOL'];
96+
const memoryCheckpoints: number[] = [];
97+
98+
console.log(`Starting memory leak test: ${iterations} iterations`);
99+
100+
// Warm-up phase (100 iterations) to let the engine stabilize
101+
for (let i = 0; i < 100; i++) {
102+
await service.updatePrices(assets);
103+
}
104+
105+
const initialMemory = process.memoryUsage().heapUsed;
106+
console.log(`Initial memory after warm-up: ${(initialMemory / 1024 / 1024).toFixed(2)} MB`);
107+
108+
// Measurement phase
109+
for (let i = 0; i < iterations; i++) {
110+
await service.updatePrices(assets);
111+
112+
// Check memory every 500 iterations
113+
if (i % 500 === 0) {
114+
const currentMemory = process.memoryUsage().heapUsed;
115+
memoryCheckpoints.push(currentMemory);
116+
console.log(`Iteration ${i}: ${(currentMemory / 1024 / 1024).toFixed(2)} MB`);
117+
}
118+
}
119+
120+
const finalMemory = process.memoryUsage().heapUsed;
121+
console.log(`Final memory: ${(finalMemory / 1024 / 1024).toFixed(2)} MB`);
122+
123+
const memoryIncrease = finalMemory - initialMemory;
124+
const memoryIncreasePercent = (memoryIncrease / initialMemory) * 100;
125+
126+
console.log(`Memory increase: ${(memoryIncrease / 1024 / 1024).toFixed(2)} MB (${memoryIncreasePercent.toFixed(2)}%)`);
127+
128+
// A leak would typically show much larger growth.
129+
// We allow for some growth due to Node.js's lazy garbage collection,
130+
// but anything over 50% increase in 2000 iterations of mocked work is suspicious.
131+
// Ideally it should be very close to 0 or even negative if GC kicks in.
132+
expect(memoryIncreasePercent).toBeLessThan(50);
133+
}, 60000); // 1 minute timeout
134+
});

oracle/tests/memory.test.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/**
2+
* Memory Stability Stress Test for Oracle Service
3+
*
4+
* Uses fake timers to fast-forward through extended operation and verifies:
5+
* - Cache Map size stays bounded (no unbounded growth)
6+
* - setInterval is properly cleared on stop()
7+
* - No event listener accumulation on the process object
8+
*/
9+
10+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
11+
import { OracleService } from '../src/index.js';
12+
import type { OracleServiceConfig } from '../src/config.js';
13+
14+
vi.mock('../src/services/contract-updater.js', () => ({
15+
createContractUpdater: vi.fn(() => ({
16+
updatePrices: vi.fn().mockResolvedValue([
17+
{ success: true, asset: 'XLM', price: 150000n, timestamp: Date.now() },
18+
]),
19+
healthCheck: vi.fn().mockResolvedValue(true),
20+
getAdminPublicKey: vi.fn().mockReturnValue('GTEST123'),
21+
})),
22+
ContractUpdater: vi.fn(),
23+
}));
24+
25+
vi.mock('../src/providers/coingecko.js', () => ({
26+
createCoinGeckoProvider: vi.fn(() => ({
27+
name: 'coingecko',
28+
isEnabled: true,
29+
priority: 1,
30+
weight: 0.6,
31+
getSupportedAssets: () => ['XLM', 'BTC', 'ETH'],
32+
fetchPrice: vi.fn().mockResolvedValue({
33+
asset: 'XLM',
34+
price: 0.15,
35+
timestamp: Math.floor(Date.now() / 1000),
36+
source: 'coingecko',
37+
}),
38+
})),
39+
}));
40+
41+
vi.mock('../src/providers/binance.js', () => ({
42+
createBinanceProvider: vi.fn(() => ({
43+
name: 'binance',
44+
isEnabled: true,
45+
priority: 2,
46+
weight: 0.4,
47+
getSupportedAssets: () => ['XLM', 'BTC', 'ETH'],
48+
fetchPrice: vi.fn().mockResolvedValue({
49+
asset: 'XLM',
50+
price: 0.152,
51+
timestamp: Math.floor(Date.now() / 1000),
52+
source: 'binance',
53+
}),
54+
})),
55+
}));
56+
57+
const BASE_CONFIG: OracleServiceConfig = {
58+
stellarNetwork: 'testnet',
59+
stellarRpcUrl: 'https://soroban-testnet.stellar.org',
60+
contractId: 'CTEST123',
61+
adminSecretKey: 'STEST123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
62+
updateIntervalMs: 1000,
63+
maxPriceDeviationPercent: 10,
64+
priceStaleThresholdSeconds: 300,
65+
cacheTtlSeconds: 30,
66+
logLevel: 'error',
67+
providers: [
68+
{
69+
name: 'coingecko',
70+
enabled: true,
71+
priority: 1,
72+
weight: 0.6,
73+
baseUrl: 'https://api.coingecko.com/api/v3',
74+
rateLimit: { maxRequests: 10, windowMs: 60000 },
75+
},
76+
{
77+
name: 'binance',
78+
enabled: true,
79+
priority: 2,
80+
weight: 0.4,
81+
baseUrl: 'https://api.binance.com/api/v3',
82+
rateLimit: { maxRequests: 1200, windowMs: 60000 },
83+
},
84+
],
85+
};
86+
87+
describe('OracleService Memory Stability', () => {
88+
let service: OracleService;
89+
90+
beforeEach(() => {
91+
vi.useFakeTimers();
92+
service = new OracleService({ ...BASE_CONFIG });
93+
});
94+
95+
afterEach(() => {
96+
service.stop();
97+
vi.useRealTimers();
98+
vi.clearAllMocks();
99+
});
100+
101+
it('cache size stays bounded after many update cycles', async () => {
102+
const assets = ['XLM', 'BTC', 'ETH'];
103+
104+
// Run 500 update cycles directly (no real time needed)
105+
for (let i = 0; i < 500; i++) {
106+
await service.updatePrices(assets);
107+
}
108+
109+
const stats = service.getStatus().aggregatorStats;
110+
// Cache has maxEntries=100 by default; it must never exceed that
111+
expect(stats.cacheStats.size).toBeLessThanOrEqual(100);
112+
// With only 3 assets the cache should hold at most 3 entries
113+
expect(stats.cacheStats.size).toBeLessThanOrEqual(assets.length);
114+
});
115+
116+
it('interval is cleared after stop()', async () => {
117+
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
118+
119+
await service.start(['XLM']);
120+
expect(service.getStatus().isRunning).toBe(true);
121+
122+
service.stop();
123+
124+
expect(clearIntervalSpy).toHaveBeenCalledTimes(1);
125+
expect(service.getStatus().isRunning).toBe(false);
126+
127+
clearIntervalSpy.mockRestore();
128+
});
129+
130+
it('no new process event listeners accumulate across start/stop cycles', async () => {
131+
const listenersBefore = process.listenerCount('uncaughtException');
132+
133+
for (let i = 0; i < 10; i++) {
134+
await service.start(['XLM']);
135+
service.stop();
136+
// Re-create service to simulate repeated instantiation
137+
service = new OracleService({ ...BASE_CONFIG });
138+
}
139+
140+
const listenersAfter = process.listenerCount('uncaughtException');
141+
expect(listenersAfter).toBeLessThanOrEqual(listenersBefore + 1);
142+
});
143+
144+
it('fast-forwarded timer triggers updates without growing circuit breaker Maps', async () => {
145+
await service.start(['XLM', 'BTC']);
146+
147+
const tickCount = 50;
148+
for (let i = 0; i < tickCount; i++) {
149+
await vi.advanceTimersByTimeAsync(BASE_CONFIG.updateIntervalMs);
150+
}
151+
152+
const metrics = service.getStatus().circuitBreakers;
153+
// Circuit breaker Map is fixed at provider count — must not grow
154+
expect(metrics.length).toBe(2); // coingecko + binance
155+
});
156+
157+
it('service is fully stopped and interval does not fire after stop()', async () => {
158+
const updateSpy = vi.spyOn(service, 'updatePrices');
159+
160+
await service.start(['XLM']);
161+
const callsAfterStart = updateSpy.mock.calls.length;
162+
163+
service.stop();
164+
165+
// Advance time well past the interval — no additional calls expected
166+
await vi.advanceTimersByTimeAsync(BASE_CONFIG.updateIntervalMs * 10);
167+
168+
expect(updateSpy.mock.calls.length).toBe(callsAfterStart);
169+
});
170+
});

stellar-lend/contracts/bridge/src/bridge.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ pub enum ContractError {
2323
AmountNotPositive = 11,
2424
AmountBelowMinimum = 12,
2525
Overflow = 13,
26+
/// Bridge acceptance (deposit) operations are paused
27+
BridgeAcceptancePaused = 14,
2628
}
2729

2830
#[contractevent]
@@ -63,6 +65,13 @@ pub struct BridgeWithdrawalEvent {
6365
pub amount: i128,
6466
}
6567

68+
#[contractevent]
69+
#[derive(Clone, Debug)]
70+
pub struct BridgeAcceptancePauseEvent {
71+
pub paused: bool,
72+
pub admin: Address,
73+
}
74+
6675
// ── Constants ─────────────────────────────────────────────────────────────────
6776

6877
const MAX_FEE_BPS: u64 = 1_000; // 10 % ceiling
@@ -89,6 +98,8 @@ pub struct BridgeConfig {
8998
pub enum DataKey {
9099
Bridge(String),
91100
BridgeList,
101+
/// Global pause flag for bridge acceptance (deposit) operations
102+
BridgeAcceptancePaused,
92103
}
93104

94105
#[contract]
@@ -261,6 +272,16 @@ impl BridgeContract {
261272
) -> Result<i128, ContractError> {
262273
sender.require_auth();
263274

275+
// Check bridge acceptance pause
276+
if env
277+
.storage()
278+
.persistent()
279+
.get::<DataKey, bool>(&DataKey::BridgeAcceptancePaused)
280+
.unwrap_or(false)
281+
{
282+
return Err(ContractError::BridgeAcceptancePaused);
283+
}
284+
264285
if amount <= 0 {
265286
return Err(ContractError::AmountNotPositive);
266287
}
@@ -345,6 +366,36 @@ impl BridgeContract {
345366
Ok(())
346367
}
347368

369+
// ── set_bridge_acceptance_paused ──────────────────────────────────────────
370+
371+
/// Admin: pause or unpause all bridge acceptance (deposit) operations.
372+
pub fn set_bridge_acceptance_paused(
373+
env: Env,
374+
caller: Address,
375+
paused: bool,
376+
) -> Result<(), ContractError> {
377+
Self::require_admin(&env, &caller)?;
378+
379+
env.storage()
380+
.persistent()
381+
.set(&DataKey::BridgeAcceptancePaused, &paused);
382+
383+
BridgeAcceptancePauseEvent {
384+
paused,
385+
admin: caller,
386+
}
387+
.publish(&env);
388+
Ok(())
389+
}
390+
391+
/// Query whether bridge acceptance is currently paused.
392+
pub fn is_bridge_acceptance_paused(env: Env) -> bool {
393+
env.storage()
394+
.persistent()
395+
.get::<DataKey, bool>(&DataKey::BridgeAcceptancePaused)
396+
.unwrap_or(false)
397+
}
398+
348399
// ── transfer_admin ────────────────────────────────────────────────────────
349400

350401
/// Admin: transfer admin rights to a new address.

0 commit comments

Comments
 (0)