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
9 changes: 9 additions & 0 deletions backend/src/config/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ const options: swaggerJsdoc.Options = {
version: '1.0.0',
description: `API documentation for FlowFi - Real-time payment streaming on Stellar

## Performance & Caching
The API implements caching for frequently accessed endpoints, such as claimable amount calculations.
- **Claimable Cache TTL**: 5 seconds
- **Invalidation**: Automatically cleared when a withdrawal event occurs.

## Sandbox Mode

FlowFi API supports sandbox mode for testing without affecting production data.
Expand Down Expand Up @@ -59,6 +64,10 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`,
name: 'Events',
description: 'Stream event tracking endpoints',
},
{
name: 'Admin',
description: 'Administrative and monitoring endpoints',
},
],
components: {
securitySchemes: {
Expand Down
92 changes: 85 additions & 7 deletions backend/src/lib/redis.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,103 @@
const Redis = require('ioredis');
/**
* Redis / In-Memory Cache Service
* Used for horizontal SSE scaling and claimable amount caching (Issue #377)
*/
import Redis from 'ioredis';
import logger from '../logger.js';

const REDIS_URL = process.env.REDIS_URL;

let _publisher: typeof Redis | null = null;
let _subscriber: typeof Redis | null = null;
let _publisher: Redis | null = null;
let _subscriber: Redis | null = null;
let _available = false;

export function getPublisher(): typeof Redis | null {
// --- Memory Cache for Claimable Amounts (Issue #377) ---
interface CacheItem<T> {
value: T;
expiresAt: number;
createdAt: number;
}

class MemoryCache {
private cache = new Map<string, CacheItem<any>>();
private hits = 0;
private misses = 0;

get<T>(key: string): T | null {
const item = this.cache.get(key);
if (!item) {
this.misses++;
return null;
}
if (Date.now() > item.expiresAt) {
this.cache.delete(key);
this.misses++;
return null;
}
this.hits++;
return item.value;
}

set<T>(key: string, value: T, ttlSeconds: number): void {
const now = Date.now();
this.cache.set(key, {
value,
createdAt: now,
expiresAt: now + ttlSeconds * 1000,
});
}

del(key: string): void {
this.cache.delete(key);
}

getMetadata(key: string) {
const item = this.cache.get(key);
if (!item) return null;
return {
createdAt: new Date(item.createdAt).toISOString(),
expiresAt: new Date(item.expiresAt).toISOString(),
};
}

getStats() {
const totalRequests = this.hits + this.misses;
return {
hits: this.hits,
misses: this.misses,
hitRate: totalRequests > 0 ? (this.hits / totalRequests) * 100 : 0,
itemCount: this.cache.size,
};
}

cleanup(): void {
const now = Date.now();
for (const [key, item] of this.cache.entries()) {
if (now > item.expiresAt) {
this.cache.delete(key);
}
}
}
}

export const cache = new MemoryCache();
setInterval(() => cache.cleanup(), 60000);

// --- Redis Pub/Sub Logic ---

export function getPublisher(): Redis | null {
return _publisher;
}

export function getSubscriber(): typeof Redis | null {
export function getSubscriber(): Redis | null {
return _subscriber;
}

export function isRedisAvailable(): boolean {
return _available;
}

function makeClient(url: string): typeof Redis {
function makeClient(url: string): Redis {
return new Redis(url, {
maxRetriesPerRequest: 3,
retryStrategy: (times: number) =>
Expand Down Expand Up @@ -64,4 +142,4 @@ export async function disconnectRedis(): Promise<void> {
_publisher = null;
_subscriber = null;
_available = false;
}
}
3 changes: 3 additions & 0 deletions backend/src/routes/v1/admin.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {

import { prisma } from '../../lib/prisma.js';
import { sseService } from '../../services/sse.service.js';
import { cache } from '../../lib/redis.js';
import logger from '../../logger.js';

const router = Router();
Expand Down Expand Up @@ -96,12 +97,14 @@ router.get('/metrics', async (_req: Request, res: Response) => {
feesLast24h: feesLast24hByToken,
},
sse: { activeConnections: sseService.getClientCount() },
cache: cache.getStats(), // Added my cache metrics
indexer: {
lastLedger: indexerState?.lastLedger ?? 0,
lagSeconds,
lastUpdated: indexerState?.updatedAt ?? null,
},
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
} catch (err) {
logger.error('Error fetching admin metrics:', err);
Expand Down
156 changes: 77 additions & 79 deletions backend/src/routes/v1/stream.routes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Router } from 'express';
import {
createStream,
listStreams,
getStream,
getStreamEvents,
import {
createStream,
listStreams,
getStream,
getStreamEvents,
getStreamClaimableAmount,
getUserStreamSummary
} from '../../controllers/stream.controller.js';

const router = Router();
Expand All @@ -16,75 +17,12 @@ const router = Router();
* tags:
* - Streams
* summary: Create a new payment stream
* description: |
* Creates a new payment stream. This endpoint indexes the stream intention.
* The actual stream creation happens on-chain via Soroban smart contracts.
*
* **Sandbox Mode:**
* - Add header `X-Sandbox-Mode: true` or query parameter `?sandbox=true`
* - Sandbox responses include `_sandbox` metadata
* - Sandbox data is stored in a separate database
* parameters:
* - in: header
* name: X-Sandbox-Mode
* schema:
* type: string
* enum: ["true", "1"]
* description: Enable sandbox mode for testing
* required: false
* - in: query
* name: sandbox
* schema:
* type: string
* enum: ["true", "1"]
* description: Enable sandbox mode via query parameter
* required: false
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sender
* - recipient
* - tokenAddress
* - amount
* - duration
* properties:
* sender:
* type: string
* description: Sender's Stellar public key
* example: "GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA"
* recipient:
* type: string
* description: Recipient's Stellar public key
* example: "GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD"
* tokenAddress:
* type: string
* description: Token contract address
* example: "CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE"
* amount:
* type: string
* description: Total amount to stream (i128 as string)
* example: "10000"
* duration:
* type: integer
* description: Stream duration in seconds
* example: 86400
* description: Creates a new payment stream on the Stellar network.
* responses:
* 201:
* description: Stream created successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Stream'
* 400:
* description: Validation error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* description: Invalid input data
*/
router.post('/', createStream);

Expand All @@ -94,8 +32,8 @@ router.post('/', createStream);
* get:
* tags:
* - Streams
* summary: List streams
* description: Retrieve a list of payment streams, optionally filtered by sender or recipient.
* summary: List payment streams
* description: Retrieve a list of payment streams with optional filtering.
* parameters:
* - in: query
* name: sender
Expand All @@ -107,26 +45,86 @@ router.post('/', createStream);
* schema:
* type: string
* description: Filter by recipient public key
* - in: query
* name: status
* schema:
* type: string
* enum: [active, cancelled, completed, paused]
* description: Filter by stream status
* - in: query
* name: limit
* schema:
* type: integer
* default: 20
* description: Maximum number of streams to return
* - in: query
* name: offset
* schema:
* type: integer
* default: 0
* description: Number of streams to skip
* - in: query
* name: sort
* schema:
* type: string
* enum: [createdAt, startTime, lastUpdateTime, depositedAmount, endTime]
* default: createdAt
* description: Field to sort by
* - in: query
* name: order
* schema:
* type: string
* enum: [asc, desc]
* default: desc
* description: Sort order
* responses:
* 200:
* description: List of streams
* description: A list of payment streams
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Stream'
* type: object
* properties:
* data:
* type: array
* items:
* $ref: '#/components/schemas/Stream'
* total:
* type: integer
* hasMore:
* type: boolean
*/
router.get('/', listStreams);

/**
* @openapi
* /v1/streams/summary/{address}:
* get:
* tags:
* - Streams
* summary: Get user stream summary
* description: Returns aggregated stream data for a user (total created, streamed in/out, current claimable).
* parameters:
* - in: path
* name: address
* required: true
* schema:
* type: string
* description: Stellar public key
* responses:
* 200:
* description: User stream summary
*/
router.get('/summary/:address', getUserStreamSummary);

/**
* @openapi
* /v1/streams/{streamId}:
* get:
* tags:
* - Streams
* summary: Get a single stream
* description: Retrieve detailed information about a specific stream by its on-chain ID.
* summary: Get stream details
* description: Retrieve detailed information about a specific stream.
* parameters:
* - in: path
* name: streamId
Expand All @@ -152,7 +150,7 @@ router.get('/:streamId', getStream);
* get:
* tags:
* - Streams
* summary: List stream events (paginated)
* summary: Get stream events
* description: |
* Retrieve events for a specific stream with offset- or cursor-based pagination.
*
Expand Down
Loading
Loading