Skip to content
Open
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
42 changes: 42 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Stellar Network Fee Estimation Caching

## Problem

Every payment initiation currently fetches the current fee from Stellar Horizon synchronously via `server.fetchBaseFee()`, adding latency to each transaction. This is particularly impactful for high-throughput payment processing where every millisecond counts.

## Solution

Implement a Redis-backed caching layer for Stellar Horizon fee statistics with a 30-second TTL, refreshed by a background cron job. The payment service reads from the cache instead of making a Horizon call per payment, eliminating redundant network round-trips.

## Changes

### New Files

| File | Purpose |
|------|---------|
| `src/services/stellarFeeStatsCache.ts` | Cache service: reads fee stats from Redis (with Horizon fallback on cache miss), fetches fresh stats from Horizon, writes to Redis with 30s TTL |
| `src/jobs/stellarFeeStatsJob.ts` | Background cron job that refreshes the fee stats cache every 30 seconds |

### Modified Files

| File | Change |
|------|--------|
| `src/stellar/transactions.ts` | `getTransactionBaseFee()` now reads from Redis cache first; falls back to `server.fetchBaseFee()` on cache miss |
| `src/jobs/scheduler.ts` | Registered the new `stellar-fee-stats` job at `*/30 * * * * *` |
| `src/index.ts` | Added fee stats cache warm-up on startup (after Redis connects) |

## How It Works

1. **Startup**: On application start, after Redis connects, the fee stats cache is immediately populated by fetching from Horizon and storing in Redis.
2. **Background refresh**: A cron job runs every 30 seconds, fetches `server.feeStats()` from Horizon, and writes `last_ledger_base_fee` to Redis under key `stellar:fee_stats` with a 30-second TTL.
3. **Payment flow**: When building a transaction, `getTransactionBaseFee()` attempts to read from Redis first. If the key exists, it returns the cached value immediately (no network call). On cache miss, it falls back to the original `server.fetchBaseFee()`.
4. **Logging**: Cache values are logged at startup and on each background refresh for observability.

## Acceptance Criteria

- [x] Background job fetches Horizon fee stats every 30 seconds and stores in Redis
- [x] Payment service reads fee from Redis cache instead of calling Horizon per request
- [x] On cache miss, fee is fetched directly from Horizon as a fallback
- [x] Cached fee values are logged at startup and on each refresh

closes #113
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,10 @@ async function initializeRuntime(): Promise<void> {
await providerSettingsService.getAllSettings();
console.log("Provider settings cache initialized");

const { updateFeeStatsCache } = await import("./services/stellarFeeStatsCache.js");
await updateFeeStatsCache();
console.log("Stellar fee stats cache initialized");

const {
startProviderBalanceAlertWorker,
scheduleProviderBalanceAlertJob,
Expand Down
7 changes: 7 additions & 0 deletions src/jobs/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { runBalanceMonitorJob } from "./balanceMonitorJob";
import { runSep31MonitorJob } from "./sep31MonitorJob";
import { runFeeBumpJob } from "./feeBumpJob";
import { runSep31FeeBumpJob } from "./sep31FeeBumpJob";
import { runStellarFeeStatsJob } from "./stellarFeeStatsJob";
import { MonitoringService } from "../services/monitoringService";
import { createPagerDutyService } from "../services/pagerDutyService";
import { runProviderBalanceAlertJob } from "./balances";
Expand Down Expand Up @@ -89,6 +90,12 @@ const JOBS: JobConfig[] = [
schedule: process.env.SEP31_FEE_BUMP_CRON || "*/30 * * * * *",
handler: runSep31FeeBumpJob,
},
{
name: "stellar-fee-stats",
// Every 30 seconds - fetches Horizon fee stats and caches in Redis
schedule: process.env.STELLAR_FEE_STATS_CRON || "*/30 * * * * *",
handler: runStellarFeeStatsJob,
},
{
name: "provider-balance-alert",
// Every 10 minutes - checks MTN/Airtel operational balances and alerts treasury when low
Expand Down
11 changes: 11 additions & 0 deletions src/jobs/stellarFeeStatsJob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { updateFeeStatsCache } from "../services/stellarFeeStatsCache";

export async function runStellarFeeStatsJob(): Promise<void> {
console.log("[stellar-fee-stats] Refreshing fee stats from Horizon");
try {
await updateFeeStatsCache();
console.log("[stellar-fee-stats] Refresh complete");
} catch (err) {
console.error("[stellar-fee-stats] Job failed", err);
}
}
62 changes: 62 additions & 0 deletions src/services/stellarFeeStatsCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { redisClient } from "../config/redis";
import { getStellarServer } from "../config/stellar";

export const FEE_STATS_CACHE_KEY = "stellar:fee_stats";
export const FEE_STATS_TTL = 30;

export interface FeeStats {
lastLedgerBaseFee: number;
lastLedger: string;
fetchedAt: string;
}

export async function getCachedBaseFee(): Promise<number | null> {
if (!redisClient?.isOpen) return null;

try {
const raw = await redisClient.get(FEE_STATS_CACHE_KEY);
if (!raw) return null;

const stats: FeeStats = JSON.parse(raw);
return stats.lastLedgerBaseFee;
} catch (err) {
console.warn("[stellar-fee-stats] Cache read failed", err);
return null;
}
}

export async function fetchFeeStatsFromHorizon(): Promise<FeeStats> {
const server = getStellarServer();
const stats = await server.feeStats();
return {
lastLedgerBaseFee: Number(stats.last_ledger_base_fee),
lastLedger: stats.last_ledger,
fetchedAt: new Date().toISOString(),
};
}

export async function updateFeeStatsCache(): Promise<void> {
if (!redisClient?.isOpen) {
console.warn(
"[stellar-fee-stats] Redis not available, skipping cache update",
);
return;
}

try {
const stats = await fetchFeeStatsFromHorizon();
await redisClient.setEx(
FEE_STATS_CACHE_KEY,
FEE_STATS_TTL,
JSON.stringify(stats),
);
console.log(
`[stellar-fee-stats] Updated cache: baseFee=${stats.lastLedgerBaseFee}, ledger=${stats.lastLedger}`,
);
} catch (err) {
console.error(
"[stellar-fee-stats] Failed to fetch fee stats from Horizon",
err,
);
}
}
6 changes: 6 additions & 0 deletions src/stellar/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getNetworkPassphrase,
getStellarServer,
} from "../config/stellar";
import { getCachedBaseFee } from "../services/stellarFeeStatsCache";

type StellarOperation = Parameters<TransactionBuilder["addOperation"]>[0];
type StellarTimebounds = { minTime: string; maxTime: string };
Expand Down Expand Up @@ -94,6 +95,11 @@ function getFeePayerKeypair(): Keypair {
}

async function getTransactionBaseFee(): Promise<number> {
const cachedBaseFee = await getCachedBaseFee();
if (cachedBaseFee !== null) {
return getConfiguredBaseFee(cachedBaseFee);
}

const server = getStellarServer();
const fetchedBaseFee = await server.fetchBaseFee();
return getConfiguredBaseFee(Number(fetchedBaseFee));
Expand Down
2 changes: 1 addition & 1 deletion src/tests/jobs/jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ describe("startJobs", () => {
it("schedules all valid jobs", () => {
(cron.validate as jest.Mock).mockReturnValue(true);
startJobs();
expect(cron.schedule).toHaveBeenCalledTimes(18);
expect(cron.schedule).toHaveBeenCalledTimes(19);
});

it("skips jobs with invalid cron expressions", () => {
Expand Down
Loading