diff --git a/backend/Dockerfile b/backend/Dockerfile index 2c951018..f66a86f0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,6 +6,7 @@ COPY package*.json ./ RUN npm install COPY tsconfig.json ./ +COPY prisma.config.ts ./ COPY src ./src COPY prisma ./prisma @@ -23,10 +24,6 @@ RUN npm install --omit=dev COPY --from=builder /app/dist ./dist COPY --from=builder /app/src/generated ./dist/generated COPY --from=builder /app/prisma ./prisma -# Prisma 7 reads the schema location and datasource url from prisma.config.ts, -# and the schema's `datasource db {}` block has no inline url. The CI health -# check runs `prisma db push` inside this image, so the config must be present -# too (dotenv is a runtime dependency, so the config loads). COPY prisma.config.ts ./ EXPOSE 3001 diff --git a/backend/src/app.ts b/backend/src/app.ts index 0ef46023..60f4610b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,26 +1,33 @@ -import express, { type Request, type Response, type NextFunction } from 'express'; -import cors from 'cors'; -import swaggerUi from 'swagger-ui-express'; -import { swaggerSpec } from './config/swagger.js'; -import { apiVersionMiddleware, type VersionedRequest } from './middleware/api-version.middleware.js'; -import { sandboxMiddleware } from './middleware/sandbox.middleware.js'; -import { globalRateLimiter } from './middleware/rate-limiter.middleware.js'; -import { requestIdMiddleware } from './middleware/requestId.js'; -import v1Routes from './routes/v1/index.js'; - -import healthRoutes from './routes/health.routes.js'; +import express, { + type Request, + type Response, + type NextFunction, +} from "express"; +import cors from "cors"; +import swaggerUi from "swagger-ui-express"; +import { swaggerSpec } from "./config/swagger.js"; +import { + apiVersionMiddleware, + type VersionedRequest, +} from "./middleware/api-version.middleware.js"; +import { sandboxMiddleware } from "./middleware/sandbox.middleware.js"; +import { globalRateLimiter } from "./middleware/rate-limiter.middleware.js"; +import { requestIdMiddleware } from "./middleware/requestId.js"; +import v1Routes from "./routes/v1/index.js"; + +import healthRoutes from "./routes/health.routes.js"; const app = express(); -const isProduction = process.env.NODE_ENV === 'production'; -const rawCors = process.env.CORS_ALLOWED_ORIGINS ?? ''; +const isProduction = process.env.NODE_ENV === "production"; +const rawCors = process.env.CORS_ALLOWED_ORIGINS ?? ""; const allowedOrigins = rawCors - .split(',') - .map((origin) => origin.trim()) - .filter(Boolean); + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); // Default in development to only localhost:3000 (frontend dev server) if (!process.env.CORS_ALLOWED_ORIGINS && !isProduction) { - allowedOrigins.push('http://localhost:3000'); + allowedOrigins.push("http://localhost:3000"); } // Apply global rate limiter first @@ -29,72 +36,88 @@ app.use(globalRateLimiter); // Request ID tracing app.use(requestIdMiddleware); -app.disable('x-powered-by'); +app.disable("x-powered-by"); // Helmet-equivalent core headers without external dependency. // Strict CSP applied globally; the /api-docs route overrides it below for Swagger UI. app.use((req: Request, res: Response, next: NextFunction) => { - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('Referrer-Policy', 'no-referrer'); - res.setHeader('X-DNS-Prefetch-Control', 'off'); - res.setHeader('X-Download-Options', 'noopen'); - res.setHeader('X-Permitted-Cross-Domain-Policies', 'none'); - res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'"); - res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); - res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); - if (process.env.NODE_ENV === 'production') { - res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); - } - next(); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("Referrer-Policy", "no-referrer"); + res.setHeader("X-DNS-Prefetch-Control", "off"); + res.setHeader("X-Download-Options", "noopen"); + res.setHeader("X-Permitted-Cross-Domain-Policies", "none"); + res.setHeader( + "Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'", + ); + res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + res.setHeader("Cross-Origin-Resource-Policy", "same-origin"); + if (process.env.NODE_ENV === "production") { + res.setHeader( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains", + ); + } + next(); }); -app.use(cors({ +app.use( + cors({ origin(origin, callback) { - // Allow non-browser clients (no Origin header) - if (!origin) { - callback(null, true); - return; - } - - if (allowedOrigins.includes(origin)) { - callback(null, true); - return; - } - - // Not allowed - callback(new Error('CORS origin not allowed')); + // Allow non-browser clients (no Origin header) + if (!origin) { + callback(null, true); + return; + } + + if (allowedOrigins.includes(origin)) { + callback(null, true); + return; + } + + // Not allowed + callback(new Error("CORS origin not allowed")); }, credentials: true, -})); + }), +); // Convert CORS errors into 403 responses so callers get a clear status code app.use((err: unknown, req: Request, res: Response, next: NextFunction) => { - if (err instanceof Error && err.message === 'CORS origin not allowed') { - res.status(403).json({ error: 'CORS origin not allowed' }); - return; - } - next(err); + if (err instanceof Error && err.message === "CORS origin not allowed") { + res.status(403).json({ error: "CORS origin not allowed" }); + return; + } + next(err); }); -app.use(express.json({ limit: '1mb' })); +app.use(express.json({ limit: "1mb" })); // Sandbox mode detection (before versioning) app.use(sandboxMiddleware); // Swagger UI setup // Override CSP for /api-docs only: Swagger UI requires inline scripts/styles. -app.use('/api-docs', (req: Request, res: Response, next: NextFunction) => { - res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'"); +app.use( + "/api-docs", + (req: Request, res: Response, next: NextFunction) => { + res.setHeader( + "Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'", + ); next(); -}, swaggerUi.serve, swaggerUi.setup(swaggerSpec, { - customCss: '.swagger-ui .topbar { display: none }', - customSiteTitle: 'FlowFi API Documentation', -})); + }, + swaggerUi.serve, + swaggerUi.setup(swaggerSpec, { + customCss: ".swagger-ui .topbar { display: none }", + customSiteTitle: "FlowFi API Documentation", + }), +); // Serve raw OpenAPI spec as JSON -app.get('/api-docs.json', (req: Request, res: Response) => { - res.setHeader('Content-Type', 'application/json'); - res.send(swaggerSpec); +app.get("/api-docs.json", (req: Request, res: Response) => { + res.setHeader("Content-Type", "application/json"); + res.send(swaggerSpec); }); // API Versioning @@ -105,16 +128,16 @@ app.use(apiVersionMiddleware); // After versioning middleware, /v1/streams becomes /streams, so we mount v1Routes at root // But only handle requests that had a version prefix (apiVersion is set) app.use((req: Request, res: Response, next: NextFunction) => { - const versionedReq = req as VersionedRequest; - if (versionedReq.apiVersion) { - // This was a versioned request, route to v1 handlers - return v1Routes(req, res, next); - } - return next(); // Not versioned, continue to deprecated handlers + const versionedReq = req as VersionedRequest; + if (versionedReq.apiVersion) { + // This was a versioned request, route to v1 handlers + return v1Routes(req, res, next); + } + return next(); // Not versioned, continue to deprecated handlers }); // Health check routes -app.use('/health', healthRoutes); +app.use("/health", healthRoutes); /** * @openapi @@ -128,11 +151,11 @@ app.use('/health', healthRoutes); * 200: * description: API is running successfully */ -app.get('/', (req: Request, res: Response) => { - res.send('FlowFi Backend is running'); +app.get("/", (req: Request, res: Response) => { + res.send("FlowFi Backend is running"); }); -import { errorHandler } from './middleware/error.middleware.js'; +import { errorHandler } from "./middleware/error.middleware.js"; app.use(errorHandler); diff --git a/backend/src/controllers/stream.controller.ts b/backend/src/controllers/stream.controller.ts index bc1c114c..9999752a 100644 --- a/backend/src/controllers/stream.controller.ts +++ b/backend/src/controllers/stream.controller.ts @@ -1,9 +1,9 @@ -import type { Request, Response } from 'express'; -import { z } from 'zod'; -import { Prisma } from '../generated/prisma/index.js'; -import { prisma } from '../lib/prisma.js'; -import logger from '../logger.js'; -import { claimableAmountService } from '../services/claimable.service.js'; +import type { Request, Response } from "express"; +import { z } from "zod"; +import { Prisma } from "../generated/prisma/index.js"; +import { prisma } from "../lib/prisma.js"; +import logger from "../logger.js"; +import { claimableAmountService } from "../services/claimable.service.js"; import { getStreamFromChain, getClaimableFromChain, @@ -11,9 +11,12 @@ import { topUpStream, pauseStream as sorobanPauseStream, resumeStream as sorobanResumeStream, -} from '../services/sorobanService.js'; -import type { AuthenticatedRequest } from '../types/auth.types.js'; -import { DEFAULT_EVENTS_PAGE_SIZE, MAX_EVENTS_PAGE_SIZE } from '../routes/v1/events.routes.js'; +} from "../services/sorobanService.js"; +import type { AuthenticatedRequest } from "../types/auth.types.js"; +import { + DEFAULT_EVENTS_PAGE_SIZE, + MAX_EVENTS_PAGE_SIZE, +} from "../routes/v1/events.routes.js"; const DEFAULT_STREAM_PAGE_SIZE = 20; const MAX_STREAM_PAGE_SIZE = 100; @@ -69,7 +72,7 @@ function sumStringI128(values: string[]): string { class StreamValidationError extends Error { constructor(message: string) { super(message); - this.name = 'StreamValidationError'; + this.name = "StreamValidationError"; } } @@ -80,13 +83,15 @@ class StreamValidationError extends Error { * normalized into a StreamValidationError so the caller can map it to 400. */ function parseRequiredBigIntField(fieldName: string, value: unknown): bigint { - if (value === undefined || value === null || value === '') { + if (value === undefined || value === null || value === "") { throw new StreamValidationError(`Missing required field: ${fieldName}`); } try { return BigInt(value as bigint | number | string | boolean); } catch { - throw new StreamValidationError(`Invalid ${fieldName}: must be a valid integer`); + throw new StreamValidationError( + `Invalid ${fieldName}: must be a valid integer`, + ); } } @@ -127,11 +132,15 @@ export const createStream = async (req: Request, res: Response) => { const parsedStartTime = Number.parseInt(startTime, 10); if (!Number.isFinite(parsedStreamId)) { - return res.status(400).json({ error: 'Invalid streamId: must be a valid integer' }); + return res + .status(400) + .json({ error: "Invalid streamId: must be a valid integer" }); } if (!Number.isFinite(parsedStartTime) || parsedStartTime < 0) { - return res.status(400).json({ error: 'Invalid startTime: must be a non-negative integer' }); + return res + .status(400) + .json({ error: "Invalid startTime: must be a non-negative integer" }); } // Presence/format validation happens here, before any BigInt coercion, @@ -140,8 +149,14 @@ export const createStream = async (req: Request, res: Response) => { let parsedRatePerSecond: bigint; let parsedDepositedAmount: bigint; try { - parsedRatePerSecond = parseRequiredBigIntField('ratePerSecond', ratePerSecond); - parsedDepositedAmount = parseRequiredBigIntField('depositedAmount', depositedAmount); + parsedRatePerSecond = parseRequiredBigIntField( + "ratePerSecond", + ratePerSecond, + ); + parsedDepositedAmount = parseRequiredBigIntField( + "depositedAmount", + depositedAmount, + ); } catch (validationError) { if (validationError instanceof StreamValidationError) { return res.status(400).json({ error: validationError.message }); @@ -150,14 +165,19 @@ export const createStream = async (req: Request, res: Response) => { } if (parsedRatePerSecond <= 0n) { - return res.status(400).json({ error: 'Invalid ratePerSecond: must be greater than zero' }); + return res + .status(400) + .json({ error: "Invalid ratePerSecond: must be greater than zero" }); } if (parsedDepositedAmount <= 0n) { - return res.status(400).json({ error: 'Invalid depositedAmount: must be greater than zero' }); + return res + .status(400) + .json({ error: "Invalid depositedAmount: must be greater than zero" }); } - const endTime = parsedStartTime + Number(parsedDepositedAmount / parsedRatePerSecond); + const endTime = + parsedStartTime + Number(parsedDepositedAmount / parsedRatePerSecond); // Issue #809: never let the upsert update branch touch a stream owned by a // different wallet. The caller is already proven to equal `sender` above, so @@ -175,7 +195,7 @@ export const createStream = async (req: Request, res: Response) => { where: { streamId: parsedStreamId }, update: { isActive: true, - lastUpdateTime: Math.floor(Date.now() / 1000) + lastUpdateTime: Math.floor(Date.now() / 1000), }, create: { streamId: parsedStreamId, @@ -187,18 +207,24 @@ export const createStream = async (req: Request, res: Response) => { withdrawnAmount: "0", startTime: parsedStartTime, endTime, - lastUpdateTime: parsedStartTime - } + lastUpdateTime: parsedStartTime, + }, }); return res.status(201).json(stream); } catch (error) { - if (error instanceof RangeError) { - logger.error('Range error in createStream:', error); - return res.status(400).json({ error: 'Invalid numeric values in request body' }); - } - logger.error('Error creating/upserting stream:', error); - return res.status(500).json({ error: 'Internal server error' }); + if ( + error instanceof RangeError || + error instanceof SyntaxError || + error instanceof TypeError + ) { + logger.error("Numeric parsing error in createStream:", error); + return res + .status(400) + .json({ error: "Invalid numeric values in request body" }); + } + logger.error("Error creating/upserting stream:", error); + return res.status(500).json({ error: "Internal server error" }); } }; @@ -212,42 +238,42 @@ export const listStreams = async (req: Request, res: Response) => { recipient, status, token, - sort = 'createdAt', - order = 'desc', - limit = '20', - offset = '0' + sort = "createdAt", + order = "desc", + limit = "20", + offset = "0", } = req.query; const where: Prisma.StreamWhereInput = {}; - if (typeof sender === 'string') where.sender = sender; - if (typeof recipient === 'string') where.recipient = recipient; - if (typeof token === 'string') where.tokenAddress = token; + if (typeof sender === "string") where.sender = sender; + if (typeof recipient === "string") where.recipient = recipient; + if (typeof token === "string") where.tokenAddress = token; // Handle status filtering - if (typeof status === 'string') { - const validStatuses = ['active', 'cancelled', 'completed', 'paused']; + if (typeof status === "string") { + const validStatuses = ["active", "cancelled", "completed", "paused"]; if (!validStatuses.includes(status)) { return res.status(400).json({ - error: 'Invalid status parameter', - message: `status must be one of: ${validStatuses.join(', ')}` + error: "Invalid status parameter", + message: `status must be one of: ${validStatuses.join(", ")}`, }); } // Map status to database conditions switch (status) { - case 'active': + case "active": where.isActive = true; where.isPaused = false; break; - case 'cancelled': + case "cancelled": where.isActive = false; - where.events = { some: { eventType: 'CANCELLED' } }; + where.events = { some: { eventType: "CANCELLED" } }; break; - case 'completed': + case "completed": where.isActive = false; - where.events = { some: { eventType: 'COMPLETED' } }; + where.events = { some: { eventType: "COMPLETED" } }; break; - case 'paused': + case "paused": where.isPaused = true; break; } @@ -255,19 +281,35 @@ export const listStreams = async (req: Request, res: Response) => { // Validate and parse pagination parameters const parsedLimit = Math.min( - typeof limit === 'string' ? (Number.parseInt(limit, 10) || DEFAULT_STREAM_PAGE_SIZE) : DEFAULT_STREAM_PAGE_SIZE, - MAX_STREAM_PAGE_SIZE + typeof limit === "string" + ? Number.parseInt(limit, 10) || DEFAULT_STREAM_PAGE_SIZE + : DEFAULT_STREAM_PAGE_SIZE, + MAX_STREAM_PAGE_SIZE, ); - const parsedOffset = typeof offset === 'string' ? (Number.parseInt(offset, 10) || 0) : 0; + const parsedOffset = + typeof offset === "string" ? Number.parseInt(offset, 10) || 0 : 0; // Validate sort field - const validSortFields = ['createdAt', 'startTime', 'lastUpdateTime', 'depositedAmount', 'endTime']; - const sortField = validSortFields.includes(typeof sort === 'string' ? sort : 'createdAt') - ? (sort as 'createdAt' | 'startTime' | 'lastUpdateTime' | 'depositedAmount' | 'endTime') - : 'createdAt'; + const validSortFields = [ + "createdAt", + "startTime", + "lastUpdateTime", + "depositedAmount", + "endTime", + ]; + const sortField = validSortFields.includes( + typeof sort === "string" ? sort : "createdAt", + ) + ? (sort as + | "createdAt" + | "startTime" + | "lastUpdateTime" + | "depositedAmount" + | "endTime") + : "createdAt"; // Validate order - const sortOrder = order === 'asc' ? 'asc' : 'desc'; + const sortOrder = order === "asc" ? "asc" : "desc"; const [streams, total] = await Promise.all([ prisma.stream.findMany({ @@ -277,10 +319,10 @@ export const listStreams = async (req: Request, res: Response) => { skip: parsedOffset, include: { senderUser: true, - recipientUser: true - } + recipientUser: true, + }, }), - prisma.stream.count({ where }) + prisma.stream.count({ where }), ]); const hasMore = parsedOffset + streams.length < total; @@ -290,11 +332,11 @@ export const listStreams = async (req: Request, res: Response) => { total, hasMore, limit: parsedLimit, - offset: parsedOffset + offset: parsedOffset, }); } catch (error) { - logger.error('Error listing streams:', error); - return res.status(500).json({ error: 'Internal server error' }); + logger.error("Error listing streams:", error); + return res.status(500).json({ error: "Internal server error" }); } }; @@ -306,10 +348,10 @@ export const getStream = async (req: Request, res: Response) => { const streamIdParam = Array.isArray(req.params.streamId) ? req.params.streamId[0] : req.params.streamId; - const parsedStreamId = Number.parseInt(streamIdParam ?? '', 10); + const parsedStreamId = Number.parseInt(streamIdParam ?? "", 10); if (!Number.isFinite(parsedStreamId)) { - return res.status(400).json({ error: 'Invalid streamId parameter' }); + return res.status(400).json({ error: "Invalid streamId parameter" }); } const stream = await prisma.stream.findUnique({ @@ -318,32 +360,34 @@ export const getStream = async (req: Request, res: Response) => { senderUser: true, recipientUser: true, events: { - orderBy: { timestamp: 'desc' } - } - } + orderBy: { timestamp: "desc" }, + }, + }, }); if (!stream) { // Fallback: try live RPC const chainStream = await getStreamFromChain(parsedStreamId); if (!chainStream) { - return res.status(404).json({ error: 'Stream not found' }); + return res.status(404).json({ error: "Stream not found" }); } - return res.status(200).json({ ...chainStream, source: 'chain' }); + return res.status(200).json({ ...chainStream, source: "chain" }); } // If DB data is stale, attempt live RPC fallback if (isStale(stream.updatedAt)) { const chainStream = await getStreamFromChain(parsedStreamId); if (chainStream) { - return res.status(200).json({ ...stream, ...chainStream, source: 'chain' }); + return res + .status(200) + .json({ ...stream, ...chainStream, source: "chain" }); } } return res.status(200).json(stream); } catch (error) { - logger.error('Error fetching stream:', error); - return res.status(500).json({ error: 'Internal server error' }); + logger.error("Error fetching stream:", error); + return res.status(500).json({ error: "Internal server error" }); } }; @@ -355,39 +399,59 @@ export const getStreamEvents = async (req: Request, res: Response) => { const streamIdParam = Array.isArray(req.params.streamId) ? req.params.streamId[0] : req.params.streamId; - const parsedStreamId = Number.parseInt(streamIdParam ?? '', 10); + const parsedStreamId = Number.parseInt(streamIdParam ?? "", 10); if (!Number.isFinite(parsedStreamId)) { - return res.status(400).json({ error: 'Invalid streamId parameter' }); + return res.status(400).json({ error: "Invalid streamId parameter" }); } - const rawLimit = req.query['limit']; - const rawOffset = req.query['offset']; - const rawPage = req.query['page']; - const cursor = typeof req.query['cursor'] === 'string' ? req.query['cursor'] : undefined; - const order = req.query['order'] === 'asc' ? 'asc' as const : 'desc' as const; - const eventType = typeof req.query['eventType'] === 'string' ? req.query['eventType'] : undefined; + const rawLimit = req.query["limit"]; + const rawOffset = req.query["offset"]; + const rawPage = req.query["page"]; + const cursor = + typeof req.query["cursor"] === "string" ? req.query["cursor"] : undefined; + const order = + req.query["order"] === "asc" ? ("asc" as const) : ("desc" as const); + const eventType = + typeof req.query["eventType"] === "string" + ? req.query["eventType"] + : undefined; const limit = Math.min( - rawLimit && typeof rawLimit === 'string' ? (Number.parseInt(rawLimit, 10) || DEFAULT_EVENTS_PAGE_SIZE) : DEFAULT_EVENTS_PAGE_SIZE, + rawLimit && typeof rawLimit === "string" + ? Number.parseInt(rawLimit, 10) || DEFAULT_EVENTS_PAGE_SIZE + : DEFAULT_EVENTS_PAGE_SIZE, MAX_EVENTS_PAGE_SIZE, ); let offset = 0; - if (rawOffset && typeof rawOffset === 'string') { + if (rawOffset && typeof rawOffset === "string") { offset = Number.parseInt(rawOffset, 10) || 0; - } else if (rawPage && typeof rawPage === 'string' && !cursor) { + } else if (rawPage && typeof rawPage === "string" && !cursor) { const page = Number.parseInt(rawPage, 10) || 1; offset = Math.max(0, (page - 1) * limit); } - const whereClause: Prisma.StreamEventWhereInput = { streamId: parsedStreamId, }; + const whereClause: Prisma.StreamEventWhereInput = { + streamId: parsedStreamId, + }; if (eventType) { - const validEventTypes = ['CREATED', 'TOPPED_UP', 'WITHDRAWN', 'CANCELLED', 'COMPLETED', 'PAUSED', 'RESUMED', 'FEE_COLLECTED', 'FEE_CONFIG_UPDATED', 'ADMIN_TRANSFERRED']; + const validEventTypes = [ + "CREATED", + "TOPPED_UP", + "WITHDRAWN", + "CANCELLED", + "COMPLETED", + "PAUSED", + "RESUMED", + "FEE_COLLECTED", + "FEE_CONFIG_UPDATED", + "ADMIN_TRANSFERRED", + ]; if (!validEventTypes.includes(eventType)) { return res.status(400).json({ - error: 'Invalid eventType parameter', - message: `eventType must be one of: ${validEventTypes.join(', ')}` + error: "Invalid eventType parameter", + message: `eventType must be one of: ${validEventTypes.join(", ")}`, }); } whereClause.eventType = eventType; @@ -402,9 +466,7 @@ export const getStreamEvents = async (req: Request, res: Response) => { // therefore cursor pagination) is stable across pages. orderBy: [{ timestamp: order }, { id: order }], take: limit, - ...(cursor - ? { cursor: { id: cursor }, skip: 1 } - : { skip: offset }), + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : { skip: offset }), }), prisma.streamEvent.count({ where: whereClause }), ]); @@ -415,8 +477,8 @@ export const getStreamEvents = async (req: Request, res: Response) => { return res.status(200).json({ data: events, total, hasMore }); } catch (error) { - logger.error('Error fetching stream events:', error); - return res.status(500).json({ error: 'Internal server error' }); + logger.error("Error fetching stream events:", error); + return res.status(500).json({ error: "Internal server error" }); } }; @@ -428,10 +490,10 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => { const streamIdParam = Array.isArray(req.params.streamId) ? req.params.streamId[0] : req.params.streamId; - const parsedStreamId = Number.parseInt(streamIdParam ?? '', 10); + const parsedStreamId = Number.parseInt(streamIdParam ?? "", 10); if (!Number.isFinite(parsedStreamId)) { - return res.status(400).json({ error: 'Invalid streamId parameter' }); + return res.status(400).json({ error: "Invalid streamId parameter" }); } const atQuery = req.query.at as string | undefined; @@ -441,7 +503,7 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => { requestedAt = Number.parseInt(atQuery, 10); if (!Number.isFinite(requestedAt) || requestedAt < 0) { return res.status(400).json({ - error: 'Invalid query parameter', + error: "Invalid query parameter", message: "'at' must be a non-negative Unix timestamp in seconds", }); } @@ -474,10 +536,10 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => { actionable: BigInt(chainClaimable) > 0n, calculatedAt: Math.floor(Date.now() / 1000), cached: false, - source: 'chain', + source: "chain", }); } - return res.status(404).json({ error: 'Stream not found' }); + return res.status(404).json({ error: "Stream not found" }); } // If DB data is stale, use live RPC @@ -490,28 +552,36 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => { actionable: BigInt(chainClaimable) > 0n, calculatedAt: Math.floor(Date.now() / 1000), cached: false, - source: 'chain', + source: "chain", }); } } - const result = claimableAmountService.getClaimableAmount(stream, requestedAt); + const result = claimableAmountService.getClaimableAmount( + stream, + requestedAt, + ); return res.status(200).json(result); } catch (error) { - logger.error('Error calculating stream claimable amount:', error); - return res.status(500).json({ error: 'Internal server error' }); + logger.error("Error calculating stream claimable amount:", error); + return res.status(500).json({ error: "Internal server error" }); } }; /** * Get user-level stream summary used by dashboard/profile cards. */ -export const getUserStreamSummary = async (req: Request<{ address: string }>, res: Response) => { +export const getUserStreamSummary = async ( + req: Request<{ address: string }>, + res: Response, +) => { try { - const address = Array.isArray(req.params.address) ? req.params.address[0] : (req.params.address ?? '').trim(); + const address = Array.isArray(req.params.address) + ? req.params.address[0] + : (req.params.address ?? "").trim(); if (!address) { - return res.status(400).json({ error: 'Address is required' }); + return res.status(400).json({ error: "Address is required" }); } const nowMs = Date.now(); @@ -562,16 +632,27 @@ export const getUserStreamSummary = async (req: Request<{ address: string }>, re let claimableInTotal = 0n; for (const stream of incomingStreams) { - const claimable = claimableAmountService.getClaimableAmount(stream, calculatedAt); + const claimable = claimableAmountService.getClaimableAmount( + stream, + calculatedAt, + ); claimableInTotal += BigInt(claimable.claimableAmount); } const totalStreamsCreated = outgoingStreams.length; - const totalStreamedOut = sumStringI128(outgoingStreams.map((stream) => stream.withdrawnAmount)); - const totalStreamedIn = sumStringI128(incomingStreams.map((stream) => stream.withdrawnAmount)); + const totalStreamedOut = sumStringI128( + outgoingStreams.map((stream: any) => stream.withdrawnAmount), + ); + const totalStreamedIn = sumStringI128( + incomingStreams.map((stream: any) => stream.withdrawnAmount), + ); - const activeOutgoingCount = outgoingStreams.filter((stream) => stream.isActive).length; - const activeIncomingCount = incomingStreams.filter((stream) => stream.isActive).length; + const activeOutgoingCount = outgoingStreams.filter( + (stream: any) => stream.isActive, + ).length; + const activeIncomingCount = incomingStreams.filter( + (stream: any) => stream.isActive, + ).length; const summary: UserStreamSummary = { address, @@ -590,13 +671,15 @@ export const getUserStreamSummary = async (req: Request<{ address: string }>, re return res.status(200).json(summary); } catch (error) { - logger.error('Error fetching user stream summary:', error); - return res.status(500).json({ error: 'Internal server error' }); + logger.error("Error fetching user stream summary:", error); + return res.status(500).json({ error: "Internal server error" }); } }; const topUpBodySchema = z.object({ - amount: z.string().regex(/^\d+$/, 'amount must be a positive integer string (XLM stroops)'), + amount: z + .string() + .regex(/^\d+$/, "amount must be a positive integer string (XLM stroops)"), }); /** @@ -605,35 +688,41 @@ const topUpBodySchema = z.object({ */ export const topUpStreamHandler = async (req: Request, res: Response) => { const streamId = parseInt( - Array.isArray(req.params.streamId) ? req.params.streamId[0]! : (req.params.streamId ?? ''), + Array.isArray(req.params.streamId) + ? req.params.streamId[0]! + : (req.params.streamId ?? ""), 10, ); if (isNaN(streamId)) { - return res.status(400).json({ error: 'Invalid streamId' }); + return res.status(400).json({ error: "Invalid streamId" }); } const parsed = topUpBodySchema.safeParse(req.body); if (!parsed.success) { - return res.status(400).json({ error: 'Validation error', details: parsed.error.issues }); + return res + .status(400) + .json({ error: "Validation error", details: parsed.error.issues }); } const amount = BigInt(parsed.data.amount); if (amount <= 0n) { - return res.status(400).json({ error: 'amount must be a positive integer' }); + return res.status(400).json({ error: "amount must be a positive integer" }); } const callerAddress = (req as AuthenticatedRequest).user?.publicKey; if (!callerAddress) { - return res.status(401).json({ error: 'Unauthorized' }); + return res.status(401).json({ error: "Unauthorized" }); } try { const stream = await prisma.stream.findUnique({ where: { streamId } }); if (!stream) { - return res.status(404).json({ error: 'Stream not found' }); + return res.status(404).json({ error: "Stream not found" }); } if (stream.sender !== callerAddress) { - return res.status(403).json({ error: 'Only the stream sender may top up this stream' }); + return res + .status(403) + .json({ error: "Only the stream sender may top up this stream" }); } if (!stream.isActive) { @@ -648,14 +737,21 @@ export const topUpStreamHandler = async (req: Request, res: Response) => { const newDeposited = (BigInt(stream.depositedAmount) + amount).toString(); await prisma.stream.update({ where: { streamId }, - data: { depositedAmount: newDeposited, lastUpdateTime: Math.floor(Date.now() / 1000) }, + data: { + depositedAmount: newDeposited, + lastUpdateTime: Math.floor(Date.now() / 1000), + }, }); logger.info(`[topUp] stream=${streamId} amount=${amount} txHash=${txHash}`); - return res.status(200).json({ streamId, txHash, depositedAmount: newDeposited }); + return res + .status(200) + .json({ streamId, txHash, depositedAmount: newDeposited }); } catch (error: any) { logger.error(`[topUp] stream=${streamId} error:`, error); - return res.status(500).json({ error: error.message ?? 'Internal server error' }); + return res + .status(500) + .json({ error: error.message ?? "Internal server error" }); } }; @@ -668,16 +764,18 @@ export const pauseStream = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; if (!authReq.user) { - return res.status(401).json({ error: 'Unauthorized', message: 'Authentication required' }); + return res + .status(401) + .json({ error: "Unauthorized", message: "Authentication required" }); } const streamIdParam = Array.isArray(req.params.streamId) ? req.params.streamId[0] : req.params.streamId; - const parsedStreamId = Number.parseInt(streamIdParam ?? '', 10); + const parsedStreamId = Number.parseInt(streamIdParam ?? "", 10); if (!Number.isFinite(parsedStreamId)) { - return res.status(400).json({ error: 'Invalid streamId parameter' }); + return res.status(400).json({ error: "Invalid streamId parameter" }); } // Fetch the stream from database @@ -686,38 +784,43 @@ export const pauseStream = async (req: Request, res: Response) => { }); if (!stream) { - return res.status(404).json({ error: 'Stream not found' }); + return res.status(404).json({ error: "Stream not found" }); } // Verify the caller is the stream sender if (stream.sender !== authReq.user.publicKey) { return res.status(403).json({ - error: 'Forbidden', - message: 'Only the stream sender can pause the stream' + error: "Forbidden", + message: "Only the stream sender can pause the stream", }); } // Check if stream is already paused if (stream.isPaused) { return res.status(409).json({ - error: 'Conflict', - message: 'Stream is already paused' + error: "Conflict", + message: "Stream is already paused", }); } // Check if stream is still active if (!stream.isActive) { return res.status(409).json({ - error: 'Conflict', - message: 'Cannot pause an inactive stream' + error: "Conflict", + message: "Cannot pause an inactive stream", }); } try { // Call Soroban service to verify the pause operation would succeed - const result = await sorobanPauseStream(authReq.user.publicKey, parsedStreamId); + const result = await sorobanPauseStream( + authReq.user.publicKey, + parsedStreamId, + ); - logger.info(`Stream ${parsedStreamId} pause simulated by ${authReq.user.publicKey}`); + logger.info( + `Stream ${parsedStreamId} pause simulated by ${authReq.user.publicKey}`, + ); return res.status(200).json({ success: true, @@ -726,15 +829,21 @@ export const pauseStream = async (req: Request, res: Response) => { stream, }); } catch (sorobanError) { - logger.error(`Soroban pause failed for stream ${parsedStreamId}:`, sorobanError); + logger.error( + `Soroban pause failed for stream ${parsedStreamId}:`, + sorobanError, + ); return res.status(400).json({ - error: 'Failed to pause stream on chain', - message: sorobanError instanceof Error ? sorobanError.message : 'Unknown error', + error: "Failed to pause stream on chain", + message: + sorobanError instanceof Error + ? sorobanError.message + : "Unknown error", }); } } catch (error) { - logger.error('Error pausing stream:', error); - return res.status(500).json({ error: 'Internal server error' }); + logger.error("Error pausing stream:", error); + return res.status(500).json({ error: "Internal server error" }); } }; @@ -747,16 +856,18 @@ export const resumeStream = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; if (!authReq.user) { - return res.status(401).json({ error: 'Unauthorized', message: 'Authentication required' }); + return res + .status(401) + .json({ error: "Unauthorized", message: "Authentication required" }); } const streamIdParam = Array.isArray(req.params.streamId) ? req.params.streamId[0] : req.params.streamId; - const parsedStreamId = Number.parseInt(streamIdParam ?? '', 10); + const parsedStreamId = Number.parseInt(streamIdParam ?? "", 10); if (!Number.isFinite(parsedStreamId)) { - return res.status(400).json({ error: 'Invalid streamId parameter' }); + return res.status(400).json({ error: "Invalid streamId parameter" }); } // Fetch the stream from database @@ -765,30 +876,35 @@ export const resumeStream = async (req: Request, res: Response) => { }); if (!stream) { - return res.status(404).json({ error: 'Stream not found' }); + return res.status(404).json({ error: "Stream not found" }); } // Verify the caller is the stream sender if (stream.sender !== authReq.user.publicKey) { return res.status(403).json({ - error: 'Forbidden', - message: 'Only the stream sender can resume the stream' + error: "Forbidden", + message: "Only the stream sender can resume the stream", }); } // Check if stream is paused if (!stream.isPaused) { return res.status(409).json({ - error: 'Conflict', - message: 'Stream is not paused' + error: "Conflict", + message: "Stream is not paused", }); } try { // Call Soroban service to verify the resume operation would succeed - const result = await sorobanResumeStream(authReq.user.publicKey, parsedStreamId); + const result = await sorobanResumeStream( + authReq.user.publicKey, + parsedStreamId, + ); - logger.info(`Stream ${parsedStreamId} resume simulated by ${authReq.user.publicKey}`); + logger.info( + `Stream ${parsedStreamId} resume simulated by ${authReq.user.publicKey}`, + ); return res.status(200).json({ success: true, @@ -797,14 +913,20 @@ export const resumeStream = async (req: Request, res: Response) => { stream, }); } catch (sorobanError) { - logger.error(`Soroban resume failed for stream ${parsedStreamId}:`, sorobanError); + logger.error( + `Soroban resume failed for stream ${parsedStreamId}:`, + sorobanError, + ); return res.status(400).json({ - error: 'Failed to resume stream on chain', - message: sorobanError instanceof Error ? sorobanError.message : 'Unknown error', + error: "Failed to resume stream on chain", + message: + sorobanError instanceof Error + ? sorobanError.message + : "Unknown error", }); } } catch (error) { - logger.error('Error resuming stream:', error); - return res.status(500).json({ error: 'Internal server error' }); + logger.error("Error resuming stream:", error); + return res.status(500).json({ error: "Internal server error" }); } }; diff --git a/backend/src/workers/soroban-event-worker.ts b/backend/src/workers/soroban-event-worker.ts index 63231189..c2ac91ab 100644 --- a/backend/src/workers/soroban-event-worker.ts +++ b/backend/src/workers/soroban-event-worker.ts @@ -1,9 +1,9 @@ -import { rpc, xdr, StrKey } from '@stellar/stellar-sdk'; -import { prisma } from '../lib/prisma.js'; -import type { Prisma } from '../generated/prisma/index.js'; -import { INDEXER_STATE_ID } from '../lib/indexer-state.js'; -import { sseService } from '../services/sse.service.js'; -import logger from '../logger.js'; +import { rpc, xdr, StrKey } from "@stellar/stellar-sdk"; +import { prisma } from "../lib/prisma.js"; +import { INDEXER_STATE_ID } from "../lib/indexer-state.js"; +import { sseService } from "../services/sse.service.js"; +import logger from "../logger.js"; +import { Prisma } from "../generated/prisma/index.js"; // ─── Config ────────────────────────────────────────────────────────────────── @@ -45,10 +45,7 @@ export function decodeI128(val: xdr.ScVal): string { */ export function decodeAddress(val: xdr.ScVal): string { const addr = val.address(); - if ( - addr.switch().value === - xdr.ScAddressType.scAddressTypeAccount().value - ) { + if (addr.switch().value === xdr.ScAddressType.scAddressTypeAccount().value) { return StrKey.encodeEd25519PublicKey(addr.accountId().ed25519()); } // addr.contractId() returns a Hash (Opaque[]); cast to Uint8Array for encodeContract @@ -84,16 +81,13 @@ export class SorobanEventWorker { constructor() { const rpcUrl = - process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org'; - this.contractId = process.env.STREAM_CONTRACT_ID ?? ''; + process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org"; + this.contractId = process.env.STREAM_CONTRACT_ID ?? ""; this.pollIntervalMs = parseInt( - process.env.INDEXER_POLL_INTERVAL_MS ?? '5000', - 10, - ); - this.startLedger = parseInt( - process.env.INDEXER_START_LEDGER ?? '0', + process.env.INDEXER_POLL_INTERVAL_MS ?? "5000", 10, ); + this.startLedger = parseInt(process.env.INDEXER_START_LEDGER ?? "0", 10); this.server = new rpc.Server(rpcUrl, { allowHttp: true }); } @@ -104,13 +98,13 @@ export class SorobanEventWorker { async start(): Promise { if (!this.contractId) { logger.warn( - '[SorobanWorker] STREAM_CONTRACT_ID is not set — event indexing disabled.', + "[SorobanWorker] STREAM_CONTRACT_ID is not set — event indexing disabled.", ); return; } this.isRunning = true; - logger.info('[SorobanWorker] Starting Soroban event indexer…'); + logger.info("[SorobanWorker] Starting Soroban event indexer…"); await this.poll(); } @@ -121,7 +115,7 @@ export class SorobanEventWorker { clearTimeout(this.pollTimer); this.pollTimer = undefined; } - logger.info('[SorobanWorker] Stopped.'); + logger.info("[SorobanWorker] Stopped."); } /** Wait for the currently-running poll batch to finish (no-op if idle). */ @@ -136,7 +130,7 @@ export class SorobanEventWorker { try { await this.fetchAndProcessEvents(); } catch (err) { - logger.error('[SorobanWorker] Manual poll error:', err); + logger.error("[SorobanWorker] Manual poll error:", err); } } @@ -147,8 +141,11 @@ export class SorobanEventWorker { this.pollTimer = setTimeout(() => this.poll(), this.pollIntervalMs); } - private async ensureSystemStream(tx: Prisma.TransactionClient): Promise { - const systemUser = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + private async ensureSystemStream( + tx: Prisma.TransactionClient, + ): Promise { + const systemUser = + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; await tx.user.upsert({ where: { publicKey: systemUser }, create: { publicKey: systemUser }, @@ -160,10 +157,11 @@ export class SorobanEventWorker { streamId: 0, sender: systemUser, recipient: systemUser, - tokenAddress: 'CDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHF', - ratePerSecond: '0', - depositedAmount: '0', - withdrawnAmount: '0', + tokenAddress: + "CDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHF", + ratePerSecond: "0", + depositedAmount: "0", + withdrawnAmount: "0", startTime: 0, lastUpdateTime: 0, endTime: 0, @@ -175,7 +173,7 @@ export class SorobanEventWorker { private async poll(): Promise { this.activeBatch = this.fetchAndProcessEvents().catch((err) => { - logger.error('[SorobanWorker] Unhandled error during poll:', err); + logger.error("[SorobanWorker] Unhandled error during poll:", err); }); try { await this.activeBatch; @@ -204,19 +202,21 @@ export class SorobanEventWorker { const baseFilter = { filters: [ { - type: 'contract' as const, + type: "contract" as const, contractIds: [this.contractId], }, ], limit: 100, - } satisfies Omit[0], 'startLedger' | 'cursor'>; + } satisfies Omit< + Parameters[0], + "startLedger" | "cursor" + >; // Prefer cursor-based pagination after the first poll so we never // re-process events. - const params: Parameters[0] = - state.lastCursor - ? { ...baseFilter, cursor: state.lastCursor } - : { ...baseFilter, startLedger: state.lastLedger || this.startLedger }; + const params: Parameters[0] = state.lastCursor + ? { ...baseFilter, cursor: state.lastCursor } + : { ...baseFilter, startLedger: state.lastLedger || this.startLedger }; const response = await this.server.getEvents(params); @@ -229,10 +229,10 @@ export class SorobanEventWorker { // This ensures that subsequent events (like 'fee_collected') that depend on // the stream existing in the DB can find it. const sortedEvents = [...response.events].sort((a, b) => { - const aType = a.topic[0] ? decodeSymbol(a.topic[0]) : ''; - const bType = b.topic[0] ? decodeSymbol(b.topic[0]) : ''; - if (aType === 'stream_created' && bType !== 'stream_created') return -1; - if (bType === 'stream_created' && aType !== 'stream_created') return 1; + const aType = a.topic[0] ? decodeSymbol(a.topic[0]) : ""; + const bType = b.topic[0] ? decodeSymbol(b.topic[0]) : ""; + if (aType === "stream_created" && bType !== "stream_created") return -1; + if (bType === "stream_created" && aType !== "stream_created") return 1; return 0; }); @@ -276,9 +276,7 @@ export class SorobanEventWorker { * Dispatch a single contract event to the appropriate handler based on the * first topic symbol. */ - public async processEvent( - event: rpc.Api.EventResponse, - ): Promise { + public async processEvent(event: rpc.Api.EventResponse): Promise { if (!event.topic || event.topic.length < 1) return; const topic0: xdr.ScVal | undefined = event.topic[0]; @@ -286,8 +284,11 @@ export class SorobanEventWorker { const eventName = decodeSymbol(topic0); - if (eventName === 'fee_config_updated' || eventName === 'admin_transferred') { - if (eventName === 'fee_config_updated') { + if ( + eventName === "fee_config_updated" || + eventName === "admin_transferred" + ) { + if (eventName === "fee_config_updated") { await this.handleFeeConfigUpdated(event); } else { await this.handleAdminTransferred(event); @@ -300,28 +301,28 @@ export class SorobanEventWorker { if (!topic1) return; switch (eventName) { - case 'stream_created': + case "stream_created": await this.handleStreamCreated(event, topic1); break; - case 'stream_topped_up': + case "stream_topped_up": await this.handleStreamToppedUp(event, topic1); break; - case 'tokens_withdrawn': + case "tokens_withdrawn": await this.handleTokensWithdrawn(event, topic1); break; - case 'stream_paused': + case "stream_paused": await this.handleStreamPaused(event, topic1); break; - case 'stream_resumed': + case "stream_resumed": await this.handleStreamResumed(event, topic1); break; - case 'stream_cancelled': + case "stream_cancelled": await this.handleStreamCancelled(event, topic1); break; - case 'stream_completed': + case "stream_completed": await this.handleStreamCompleted(event, topic1); break; - case 'fee_collected': + case "fee_collected": await this.handleFeeCollected(event, topic1); break; default: @@ -336,30 +337,35 @@ export class SorobanEventWorker { const body = decodeMap(event.value); if ( - !body['admin'] || - !body['old_treasury'] || - !body['new_treasury'] || - body['old_fee_rate_bps'] === undefined || - body['new_fee_rate_bps'] === undefined + !body["admin"] || + !body["old_treasury"] || + !body["new_treasury"] || + body["old_fee_rate_bps"] === undefined || + body["new_fee_rate_bps"] === undefined ) { - throw new Error('FeeConfigUpdated: missing body fields'); + throw new Error("FeeConfigUpdated: missing body fields"); } - const admin = decodeAddress(body['admin']); - const oldTreasury = decodeAddress(body['old_treasury']); - const newTreasury = decodeAddress(body['new_treasury']); - const oldFeeRateBps = decodeU32(body['old_fee_rate_bps']); - const newFeeRateBps = decodeU32(body['new_fee_rate_bps']); + const admin = decodeAddress(body["admin"]); + const oldTreasury = decodeAddress(body["old_treasury"]); + const newTreasury = decodeAddress(body["new_treasury"]); + const oldFeeRateBps = decodeU32(body["old_fee_rate_bps"]); + const newFeeRateBps = decodeU32(body["new_fee_rate_bps"]); const timestamp = Math.floor(Date.now() / 1000); await prisma.$transaction(async (tx: Prisma.TransactionClient) => { await this.ensureSystemStream(tx); await tx.streamEvent.upsert({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'FEE_CONFIG_UPDATED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "FEE_CONFIG_UPDATED", + }, + }, create: { streamId: 0, - eventType: 'FEE_CONFIG_UPDATED', + eventType: "FEE_CONFIG_UPDATED", transactionHash: event.txHash, ledgerSequence: event.ledger, timestamp, @@ -375,7 +381,7 @@ export class SorobanEventWorker { }); }); - sseService.broadcastToAdmin('stream.fee_config_updated', { + sseService.broadcastToAdmin("stream.fee_config_updated", { admin, oldTreasury, newTreasury, @@ -392,22 +398,27 @@ export class SorobanEventWorker { ): Promise { const body = decodeMap(event.value); - if (!body['previous_admin'] || !body['new_admin']) { - throw new Error('AdminTransferred: missing body fields'); + if (!body["previous_admin"] || !body["new_admin"]) { + throw new Error("AdminTransferred: missing body fields"); } - const previousAdmin = decodeAddress(body['previous_admin']); - const newAdmin = decodeAddress(body['new_admin']); + const previousAdmin = decodeAddress(body["previous_admin"]); + const newAdmin = decodeAddress(body["new_admin"]); const timestamp = Math.floor(Date.now() / 1000); await prisma.$transaction(async (tx: Prisma.TransactionClient) => { await this.ensureSystemStream(tx); await tx.streamEvent.upsert({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'ADMIN_TRANSFERRED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "ADMIN_TRANSFERRED", + }, + }, create: { streamId: 0, - eventType: 'ADMIN_TRANSFERRED', + eventType: "ADMIN_TRANSFERRED", transactionHash: event.txHash, ledgerSequence: event.ledger, timestamp, @@ -421,7 +432,7 @@ export class SorobanEventWorker { }); }); - sseService.broadcastToAdmin('stream.admin_transferred', { + sseService.broadcastToAdmin("stream.admin_transferred", { previousAdmin, newAdmin, transactionHash: event.txHash, @@ -440,22 +451,22 @@ export class SorobanEventWorker { const body = decodeMap(event.value); if ( - !body['sender'] || - !body['recipient'] || - !body['token_address'] || - !body['rate_per_second'] || - !body['deposited_amount'] || - !body['start_time'] + !body["sender"] || + !body["recipient"] || + !body["token_address"] || + !body["rate_per_second"] || + !body["deposited_amount"] || + !body["start_time"] ) { throw new Error(`StreamCreated #${streamId}: missing body fields`); } - const sender = decodeAddress(body['sender']); - const recipient = decodeAddress(body['recipient']); - const tokenAddress = decodeAddress(body['token_address']); - const ratePerSecond = decodeI128(body['rate_per_second']); - const depositedAmount = decodeI128(body['deposited_amount']); - const startTime = Number(decodeU64(body['start_time'])); + const sender = decodeAddress(body["sender"]); + const recipient = decodeAddress(body["recipient"]); + const tokenAddress = decodeAddress(body["token_address"]); + const ratePerSecond = decodeI128(body["rate_per_second"]); + const depositedAmount = decodeI128(body["deposited_amount"]); + const startTime = Number(decodeU64(body["start_time"])); const ratePerSecondBigInt = BigInt(ratePerSecond); const endTime = @@ -484,7 +495,7 @@ export class SorobanEventWorker { tokenAddress, ratePerSecond, depositedAmount, - withdrawnAmount: '0', + withdrawnAmount: "0", startTime, endTime, lastUpdateTime: startTime, @@ -502,17 +513,29 @@ export class SorobanEventWorker { }); const existingEvent = await tx.streamEvent.findUnique({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'CREATED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "CREATED", + }, + }, select: { id: true }, }); if (existingEvent) { - logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=CREATED`); + logger.warn( + `[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=CREATED`, + ); } else { await tx.streamEvent.upsert({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'CREATED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "CREATED", + }, + }, create: { streamId, - eventType: 'CREATED', + eventType: "CREATED", amount: depositedAmount, transactionHash: event.txHash, ledgerSequence: event.ledger, @@ -524,7 +547,7 @@ export class SorobanEventWorker { } }); - sseService.broadcastToStream(String(streamId), 'stream.created', { + sseService.broadcastToStream(String(streamId), "stream.created", { streamId, sender, recipient, @@ -544,12 +567,12 @@ export class SorobanEventWorker { const streamId = Number(decodeU64(streamIdTopic)); const body = decodeMap(event.value); - if (!body['amount'] || !body['new_deposited_amount']) { + if (!body["amount"] || !body["new_deposited_amount"]) { throw new Error(`StreamToppedUp #${streamId}: missing body fields`); } - const amount = decodeI128(body['amount']); - const newDepositedAmount = decodeI128(body['new_deposited_amount']); + const amount = decodeI128(body["amount"]); + const newDepositedAmount = decodeI128(body["new_deposited_amount"]); const timestamp = Math.floor(Date.now() / 1000); await prisma.$transaction(async (tx: Prisma.TransactionClient) => { @@ -566,7 +589,11 @@ export class SorobanEventWorker { const stream = await tx.stream.findUniqueOrThrow({ where: { streamId }, - select: { ratePerSecond: true, startTime: true, totalPausedDuration: true } + select: { + ratePerSecond: true, + startTime: true, + totalPausedDuration: true, + }, }); const ratePerSecondBigInt = BigInt(stream.ratePerSecond); @@ -574,8 +601,8 @@ export class SorobanEventWorker { ratePerSecondBigInt === 0n ? null : stream.startTime + - Number(BigInt(newDepositedAmount) / ratePerSecondBigInt) + - stream.totalPausedDuration; + Number(BigInt(newDepositedAmount) / ratePerSecondBigInt) + + stream.totalPausedDuration; await tx.stream.update({ where: { streamId }, @@ -601,7 +628,7 @@ export class SorobanEventWorker { }); }); - sseService.broadcastToStream(String(streamId), 'stream.topped_up', { + sseService.broadcastToStream(String(streamId), "stream.topped_up", { streamId, amount, newDepositedAmount, @@ -618,13 +645,13 @@ export class SorobanEventWorker { const streamId = Number(decodeU64(streamIdTopic)); const body = decodeMap(event.value); - if (!body['recipient'] || !body['amount'] || !body['timestamp']) { + if (!body["recipient"] || !body["amount"] || !body["timestamp"]) { throw new Error(`TokensWithdrawn #${streamId}: missing body fields`); } - const recipient = decodeAddress(body['recipient']); - const amount = decodeI128(body['amount']); - const timestamp = Number(decodeU64(body['timestamp'])); + const recipient = decodeAddress(body["recipient"]); + const amount = decodeI128(body["amount"]); + const timestamp = Number(decodeU64(body["timestamp"])); await prisma.$transaction(async (tx: Prisma.TransactionClient) => { // Check for a duplicate BEFORE mutating any Stream fields so that a @@ -670,7 +697,7 @@ export class SorobanEventWorker { }); }); - sseService.broadcastToStream(String(streamId), 'stream.withdrawn', { + sseService.broadcastToStream(String(streamId), "stream.withdrawn", { streamId, recipient, amount, @@ -687,12 +714,12 @@ export class SorobanEventWorker { const streamId = Number(decodeU64(streamIdTopic)); const body = decodeMap(event.value); - if (!body['amount_withdrawn'] || !body['refunded_amount']) { + if (!body["amount_withdrawn"] || !body["refunded_amount"]) { throw new Error(`StreamCancelled #${streamId}: missing body fields`); } - const amountWithdrawn = decodeI128(body['amount_withdrawn']); - const refundedAmount = decodeI128(body['refunded_amount']); + const amountWithdrawn = decodeI128(body["amount_withdrawn"]); + const refundedAmount = decodeI128(body["refunded_amount"]); const timestamp = Math.floor(Date.now() / 1000); await prisma.$transaction(async (tx: Prisma.TransactionClient) => { @@ -706,17 +733,29 @@ export class SorobanEventWorker { }); const existingEvent = await tx.streamEvent.findUnique({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'CANCELLED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "CANCELLED", + }, + }, select: { id: true }, }); if (existingEvent) { - logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=CANCELLED`); + logger.warn( + `[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=CANCELLED`, + ); } else { await tx.streamEvent.upsert({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'CANCELLED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "CANCELLED", + }, + }, create: { streamId, - eventType: 'CANCELLED', + eventType: "CANCELLED", amount: refundedAmount, transactionHash: event.txHash, ledgerSequence: event.ledger, @@ -728,7 +767,7 @@ export class SorobanEventWorker { } }); - sseService.broadcastToStream(String(streamId), 'stream.cancelled', { + sseService.broadcastToStream(String(streamId), "stream.cancelled", { streamId, refundedAmount, amountWithdrawn, @@ -745,12 +784,12 @@ export class SorobanEventWorker { const streamId = Number(decodeU64(streamIdTopic)); const body = decodeMap(event.value); - if (!body['recipient'] || !body['total_withdrawn']) { + if (!body["recipient"] || !body["total_withdrawn"]) { throw new Error(`StreamCompleted #${streamId}: missing body fields`); } - const recipient = decodeAddress(body['recipient']); - const totalWithdrawn = decodeI128(body['total_withdrawn']); + const recipient = decodeAddress(body["recipient"]); + const totalWithdrawn = decodeI128(body["total_withdrawn"]); const timestamp = Math.floor(Date.now() / 1000); await prisma.$transaction(async (tx: Prisma.TransactionClient) => { @@ -764,17 +803,29 @@ export class SorobanEventWorker { }); const existingEvent = await tx.streamEvent.findUnique({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'COMPLETED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "COMPLETED", + }, + }, select: { id: true }, }); if (existingEvent) { - logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=COMPLETED`); + logger.warn( + `[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=COMPLETED`, + ); } else { await tx.streamEvent.upsert({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'COMPLETED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "COMPLETED", + }, + }, create: { streamId, - eventType: 'COMPLETED', + eventType: "COMPLETED", amount: totalWithdrawn, transactionHash: event.txHash, ledgerSequence: event.ledger, @@ -786,7 +837,7 @@ export class SorobanEventWorker { } }); - sseService.broadcastToStream(String(streamId), 'stream.completed', { + sseService.broadcastToStream(String(streamId), "stream.completed", { streamId, recipient, totalWithdrawn, @@ -803,27 +854,39 @@ export class SorobanEventWorker { const streamId = Number(decodeU64(streamIdTopic)); const body = decodeMap(event.value); - if (!body['treasury'] || !body['fee_amount'] || !body['token']) { + if (!body["treasury"] || !body["fee_amount"] || !body["token"]) { throw new Error(`FeeCollected #${streamId}: missing body fields`); } - const treasury = decodeAddress(body['treasury']); - const feeAmount = decodeI128(body['fee_amount']); - const token = decodeAddress(body['token']); + const treasury = decodeAddress(body["treasury"]); + const feeAmount = decodeI128(body["fee_amount"]); + const token = decodeAddress(body["token"]); const timestamp = Math.floor(Date.now() / 1000); const existingEvent = await prisma.streamEvent.findUnique({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'FEE_COLLECTED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "FEE_COLLECTED", + }, + }, select: { id: true }, }); if (existingEvent) { - logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=FEE_COLLECTED`); + logger.warn( + `[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=FEE_COLLECTED`, + ); } else { await prisma.streamEvent.upsert({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'FEE_COLLECTED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "FEE_COLLECTED", + }, + }, create: { streamId, - eventType: 'FEE_COLLECTED', + eventType: "FEE_COLLECTED", amount: feeAmount, transactionHash: event.txHash, ledgerSequence: event.ledger, @@ -835,7 +898,7 @@ export class SorobanEventWorker { } // Broadcast to admin channel for treasury reporting - sseService.broadcastToAdmin('stream.fee_collected', { + sseService.broadcastToAdmin("stream.fee_collected", { streamId, treasury, feeAmount, @@ -852,12 +915,12 @@ export class SorobanEventWorker { const streamId = Number(decodeU64(streamIdTopic)); const body = decodeMap(event.value); - if (!body['sender'] || !body['paused_at']) { + if (!body["sender"] || !body["paused_at"]) { throw new Error(`StreamPaused #${streamId}: missing body fields`); } - const sender = decodeAddress(body['sender']); - const pausedAt = Number(decodeU64(body['paused_at'])); + const sender = decodeAddress(body["sender"]); + const pausedAt = Number(decodeU64(body["paused_at"])); const timestamp = Math.floor(Date.now() / 1000); await prisma.$transaction(async (tx: Prisma.TransactionClient) => { @@ -871,17 +934,29 @@ export class SorobanEventWorker { }); const existingEvent = await tx.streamEvent.findUnique({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'PAUSED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "PAUSED", + }, + }, select: { id: true }, }); if (existingEvent) { - logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=PAUSED`); + logger.warn( + `[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=PAUSED`, + ); } else { await tx.streamEvent.upsert({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'PAUSED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "PAUSED", + }, + }, create: { streamId, - eventType: 'PAUSED', + eventType: "PAUSED", transactionHash: event.txHash, ledgerSequence: event.ledger, timestamp, @@ -892,7 +967,7 @@ export class SorobanEventWorker { } }); - sseService.broadcastToStream(String(streamId), 'stream.paused', { + sseService.broadcastToStream(String(streamId), "stream.paused", { streamId, sender, pausedAt, @@ -909,12 +984,12 @@ export class SorobanEventWorker { const streamId = Number(decodeU64(streamIdTopic)); const body = decodeMap(event.value); - if (!body['sender'] || !body['new_end_time']) { + if (!body["sender"] || !body["new_end_time"]) { throw new Error(`StreamResumed #${streamId}: missing body fields`); } - const sender = decodeAddress(body['sender']); - const newEndTime = Number(decodeU64(body['new_end_time'])); + const sender = decodeAddress(body["sender"]); + const newEndTime = Number(decodeU64(body["new_end_time"])); const timestamp = Math.floor(Date.now() / 1000); await prisma.$transaction(async (tx: Prisma.TransactionClient) => { @@ -930,7 +1005,8 @@ export class SorobanEventWorker { additionalPausedDuration = timestamp - currentStream.pausedAt; } - const newTotalPausedDuration = currentStream.totalPausedDuration + additionalPausedDuration; + const newTotalPausedDuration = + currentStream.totalPausedDuration + additionalPausedDuration; await tx.stream.update({ where: { streamId }, @@ -944,17 +1020,29 @@ export class SorobanEventWorker { }); const existingEvent = await tx.streamEvent.findUnique({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'RESUMED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "RESUMED", + }, + }, select: { id: true }, }); if (existingEvent) { - logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=RESUMED`); + logger.warn( + `[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=RESUMED`, + ); } else { await tx.streamEvent.upsert({ - where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'RESUMED' } }, + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "RESUMED", + }, + }, create: { streamId, - eventType: 'RESUMED', + eventType: "RESUMED", transactionHash: event.txHash, ledgerSequence: event.ledger, timestamp, @@ -970,7 +1058,7 @@ export class SorobanEventWorker { } }); - sseService.broadcastToStream(String(streamId), 'stream.resumed', { + sseService.broadcastToStream(String(streamId), "stream.resumed", { streamId, sender, newEndTime, diff --git a/backend/tests/stream.controller.test.ts b/backend/tests/stream.controller.test.ts index bfd77587..7a208ce1 100644 --- a/backend/tests/stream.controller.test.ts +++ b/backend/tests/stream.controller.test.ts @@ -1,11 +1,24 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createStream, listStreams, getStream, getStreamClaimableAmount, pauseStream, resumeStream } from '../src/controllers/stream.controller.js'; -import { prisma } from '../src/lib/prisma.js'; -import { claimableAmountService } from '../src/services/claimable.service.js'; -import * as sorobanService from '../src/services/sorobanService.js'; -import type { Request, Response } from 'express'; - -vi.mock('../src/lib/prisma.js', () => ({ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + createStream, + listStreams, + getStream, + getStreamClaimableAmount, + pauseStream, + resumeStream, +} from "../src/controllers/stream.controller.js"; +import { prisma } from "../src/lib/prisma.js"; +import { claimableAmountService } from "../src/services/claimable.service.js"; +import * as sorobanService from "../src/services/sorobanService.js"; +import type { Request, Response } from "express"; + +type TestRequest = Partial & { + user?: { + publicKey: string; + }; +}; + +vi.mock("../src/lib/prisma.js", () => ({ prisma: { stream: { upsert: vi.fn(), @@ -20,20 +33,20 @@ vi.mock('../src/lib/prisma.js', () => ({ }, })); -vi.mock('../src/services/claimable.service.js', () => ({ +vi.mock("../src/services/claimable.service.js", () => ({ claimableAmountService: { getClaimableAmount: vi.fn(), }, })); -vi.mock('../src/services/sorobanService.js', () => ({ +vi.mock("../src/services/sorobanService.js", () => ({ isStale: vi.fn(), getStreamFromChain: vi.fn(), pauseStream: vi.fn(), resumeStream: vi.fn(), })); -vi.mock('../src/logger.js', () => ({ +vi.mock("../src/logger.js", () => ({ default: { info: vi.fn(), error: vi.fn(), @@ -41,8 +54,8 @@ vi.mock('../src/logger.js', () => ({ }, })); -describe('Stream Controller', () => { - let req: Partial; +describe("Stream Controller", () => { + let req: TestRequest; let res: Partial; beforeEach(() => { @@ -51,16 +64,19 @@ describe('Stream Controller', () => { (sorobanService.getStreamFromChain as any).mockResolvedValue(null); req = { body: { - streamId: '123', - sender: 'GSENDER', - recipient: 'GRECIPIENT', - tokenAddress: 'T1', - ratePerSecond: '10', - depositedAmount: '1000', - startTime: '1622505600', + streamId: "123", + sender: "GSENDER", + recipient: "GRECIPIENT", + tokenAddress: "T1", + ratePerSecond: "10", + depositedAmount: "1000", + startTime: "1622505600", }, query: {}, params: {}, + user: { + publicKey: "GSENDER", + }, }; // Authenticated caller matches body.sender by default (Issue #809). (req as any).user = { publicKey: 'GSENDER' }; @@ -133,8 +149,8 @@ describe('Stream Controller', () => { expect(res.status).toHaveBeenCalledWith(400); }); - it('should return 400 for non-positive ratePerSecond', async () => { - req.body.ratePerSecond = '0'; + it("should return 400 when a required numeric field is missing", async () => { + delete req.body.depositedAmount; await createStream(req as Request, res as Response); expect(res.status).toHaveBeenCalledWith(400); }); @@ -180,22 +196,28 @@ describe('Stream Controller', () => { }); }); - describe('listStreams', () => { - it('should list streams with pagination', async () => { - req.query = { address: 'GD2XP6FNWL6IWULVMPNA2RV2T7GLCJHK3RH75GBCY7TSVIWDITJN4FXJ', limit: '10', offset: '0' }; + describe("listStreams", () => { + it("should list streams with pagination", async () => { + req.query = { + address: "GD2XP6FNWL6IWULVMPNA2RV2T7GLCJHK3RH75GBCY7TSVIWDITJN4FXJ", + limit: "10", + offset: "0", + }; (prisma.stream.findMany as any).mockResolvedValue([]); (prisma.stream.count as any).mockResolvedValue(0); await listStreams(req as Request, res as Response); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ total: 0 })); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ total: 0 }), + ); }); }); - describe('getStream', () => { - it('should return 404 if stream not found', async () => { - req.params = { streamId: '999' }; + describe("getStream", () => { + it("should return 404 if stream not found", async () => { + req.params = { streamId: "999" }; (prisma.stream.findUnique as any).mockResolvedValue(null); await getStream(req as Request, res as Response); @@ -203,38 +225,60 @@ describe('Stream Controller', () => { expect(res.status).toHaveBeenCalledWith(404); }); - it('should return stream if found', async () => { - req.params = { streamId: '123' }; - (prisma.stream.findUnique as any).mockResolvedValue({ streamId: 123, updatedAt: new Date() }); + it("should return stream if found", async () => { + req.params = { streamId: "123" }; + (prisma.stream.findUnique as any).mockResolvedValue({ + streamId: 123, + updatedAt: new Date(), + }); await getStream(req as Request, res as Response); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ streamId: 123 })); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ streamId: 123 }), + ); }); }); - describe('getStreamClaimableAmount', () => { - it('should return claimable amount', async () => { - req.params = { streamId: '123' }; - (prisma.stream.findUnique as any).mockResolvedValue({ streamId: 123, updatedAt: new Date() }); - (claimableAmountService.getClaimableAmount as any).mockReturnValue({ claimableAmount: '100' }); + describe("getStreamClaimableAmount", () => { + it("should return claimable amount", async () => { + req.params = { streamId: "123" }; + (prisma.stream.findUnique as any).mockResolvedValue({ + streamId: 123, + updatedAt: new Date(), + }); + (claimableAmountService.getClaimableAmount as any).mockReturnValue({ + claimableAmount: "100", + }); await getStreamClaimableAmount(req as Request, res as Response); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ claimableAmount: '100' })); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ claimableAmount: "100" }), + ); }); }); - describe('pauseStream', () => { - it('should pause stream', async () => { - req.params = { streamId: '123' }; - req.body = { secret: 'S123' }; - (req as any).user = { publicKey: 'GUSER1' }; - (prisma.stream.findUnique as any).mockResolvedValue({ streamId: 123, sender: 'GUSER1', isPaused: false, isActive: true }); - (sorobanService.pauseStream as any).mockResolvedValue({ txHash: 'tx123' }); - (prisma.stream.update as any).mockResolvedValue({ streamId: 123, isPaused: true }); + describe("pauseStream", () => { + it("should pause stream", async () => { + req.params = { streamId: "123" }; + req.body = { secret: "S123" }; + (req as any).user = { publicKey: "GUSER1" }; + (prisma.stream.findUnique as any).mockResolvedValue({ + streamId: 123, + sender: "GUSER1", + isPaused: false, + isActive: true, + }); + (sorobanService.pauseStream as any).mockResolvedValue({ + txHash: "tx123", + }); + (prisma.stream.update as any).mockResolvedValue({ + streamId: 123, + isPaused: true, + }); await pauseStream(req as Request, res as Response); @@ -242,14 +286,25 @@ describe('Stream Controller', () => { }); }); - describe('resumeStream', () => { - it('should resume stream', async () => { - req.params = { streamId: '123' }; - req.body = { secret: 'S123' }; - (req as any).user = { publicKey: 'GUSER1' }; - (prisma.stream.findUnique as any).mockResolvedValue({ streamId: 123, sender: 'GUSER1', isPaused: true, isActive: true, pausedAt: Math.floor(Date.now() / 1000) }); - (sorobanService.resumeStream as any).mockResolvedValue({ txHash: 'tx123' }); - (prisma.stream.update as any).mockResolvedValue({ streamId: 123, isPaused: false }); + describe("resumeStream", () => { + it("should resume stream", async () => { + req.params = { streamId: "123" }; + req.body = { secret: "S123" }; + (req as any).user = { publicKey: "GUSER1" }; + (prisma.stream.findUnique as any).mockResolvedValue({ + streamId: 123, + sender: "GUSER1", + isPaused: true, + isActive: true, + pausedAt: Math.floor(Date.now() / 1000), + }); + (sorobanService.resumeStream as any).mockResolvedValue({ + txHash: "tx123", + }); + (prisma.stream.update as any).mockResolvedValue({ + streamId: 123, + isPaused: false, + }); await resumeStream(req as Request, res as Response); diff --git a/backend/tests/stream.test.ts b/backend/tests/stream.test.ts index 98c2053f..cad05ac1 100644 --- a/backend/tests/stream.test.ts +++ b/backend/tests/stream.test.ts @@ -101,6 +101,68 @@ describe('POST /v1/streams', () => { }); }); + it('should return 400 for malformed numeric fields', async () => { + const invalidData = { + streamId: '2', + sender: 'GTEST_USER_PUBLIC_KEY', + recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD', + tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE', + ratePerSecond: 'not-a-number', + depositedAmount: 'also-not-a-number', + startTime: '1700000000', + }; + + const response = await request(app) + .post('/v1/streams') + .send(invalidData) + .set('Accept', 'application/json'); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + expect(prisma.stream.upsert).not.toHaveBeenCalled(); + }); + + it('should return 400 when a required numeric field is missing', async () => { + const invalidData = { + streamId: '2', + sender: 'GTEST_USER_PUBLIC_KEY', + recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD', + tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE', + ratePerSecond: '100', + startTime: '1700000000', + }; + + const response = await request(app) + .post('/v1/streams') + .send(invalidData) + .set('Accept', 'application/json'); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + expect(prisma.stream.upsert).not.toHaveBeenCalled(); + }); + + it('should return 403 when an authenticated non-owner attempts to reactivate a stream via upsert', async () => { + const validData = { + streamId: '2', + sender: 'GDIFFERENT_SENDER_PUBLIC_KEY', + recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD', + tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE', + ratePerSecond: '100', + depositedAmount: '86400', + startTime: '1700000000', + }; + + const response = await request(app) + .post('/v1/streams') + .send(validData) + .set('Accept', 'application/json'); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('error', 'Forbidden'); + expect(prisma.stream.upsert).not.toHaveBeenCalled(); + }); + it('should return 500 when stream creation fails (DB error)', async () => { (prisma.stream.upsert as ReturnType).mockRejectedValue( new Error('DB connection failed') @@ -174,9 +236,9 @@ describe('GET /v1/users/:address/summary', () => { it('returns accurate outgoing/incoming aggregates and claimable sum', async () => { vi.mocked(prisma.stream.findMany) .mockResolvedValueOnce([ - { - id: '1', - createdAt: new Date(), + { + id: '1', + createdAt: new Date(), updatedAt: new Date(), streamId: 1, sender: 'GSENDER', @@ -184,18 +246,18 @@ describe('GET /v1/users/:address/summary', () => { tokenAddress: 'TOKEN', ratePerSecond: '10', depositedAmount: '100', - withdrawnAmount: '30', + withdrawnAmount: '30', startTime: 1000, lastUpdateTime: 2000, isPaused: false, endTime: null, pausedAt: null, totalPausedDuration: 0, - isActive: true + isActive: true }, - { - id: '2', - createdAt: new Date(), + { + id: '2', + createdAt: new Date(), updatedAt: new Date(), streamId: 2, sender: 'GSENDER2', @@ -203,14 +265,14 @@ describe('GET /v1/users/:address/summary', () => { tokenAddress: 'TOKEN2', ratePerSecond: '20', depositedAmount: '200', - withdrawnAmount: '20', + withdrawnAmount: '20', startTime: 1000, lastUpdateTime: 2000, isPaused: false, endTime: null, pausedAt: null, totalPausedDuration: 0, - isActive: false + isActive: false }, ]) .mockResolvedValueOnce([ @@ -271,7 +333,7 @@ describe('GET /v1/users/:address/summary', () => { it('caches summary results for repeated requests within TTL', async () => { vi.mocked(prisma.stream.findMany) - .mockResolvedValueOnce([{ + .mockResolvedValueOnce([{ id: '5', createdAt: new Date(), updatedAt: new Date(), @@ -288,7 +350,7 @@ describe('GET /v1/users/:address/summary', () => { endTime: null, pausedAt: null, totalPausedDuration: 0, - isActive: true + isActive: true }]) .mockResolvedValueOnce([]); diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx index 5dac949f..e0a921e3 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -15,7 +15,6 @@ import toast from "react-hot-toast"; * - Error state: "Failed to load streams" with a retry button */ - import { getDashboardAnalytics, fetchDashboardData, @@ -109,9 +108,10 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ function SkeletonCard({ className = "" }: { className?: string }) { return (