diff --git a/backend/src/controllers/stream.controller.ts b/backend/src/controllers/stream.controller.ts index cd2a86d6..97ca4065 100644 --- a/backend/src/controllers/stream.controller.ts +++ b/backend/src/controllers/stream.controller.ts @@ -389,8 +389,13 @@ export const getUserStreamSummary = async (req: Request, res: Response) => { prisma.stream.findMany({ where: { sender: address }, select: { + streamId: true, + ratePerSecond: true, + depositedAmount: true, withdrawnAmount: true, + lastUpdateTime: true, isActive: true, + updatedAt: true, }, }), prisma.stream.findMany({ @@ -408,19 +413,38 @@ export const getUserStreamSummary = async (req: Request, res: Response) => { ]); const totalStreamsCreated = outgoingStreams.length; - const totalStreamedOut = sumStringI128(outgoingStreams.map((stream: any) => stream.withdrawnAmount)); - const totalStreamedIn = sumStringI128(incomingStreams.map((stream: any) => stream.withdrawnAmount)); - const activeOutgoingCount = outgoingStreams.filter((stream: any) => stream.isActive).length; - const activeIncomingCount = incomingStreams.filter((stream: any) => stream.isActive).length; - const calculatedAt = Math.floor(nowMs / 1000); + + let totalStreamedOutBigInt = 0n; + let activeOutgoingCount = 0; + for (const stream of outgoingStreams) { + totalStreamedOutBigInt += BigInt(stream.withdrawnAmount); + if (stream.isActive) { + activeOutgoingCount++; + const claimable = claimableAmountService.getClaimableAmount(stream, calculatedAt); + totalStreamedOutBigInt += BigInt(claimable.claimableAmount); + } + } + + let totalStreamedInBigInt = 0n; let claimableTotal = 0n; + let activeIncomingCount = 0; for (const stream of incomingStreams) { - if (!stream.isActive) continue; - const claimable = claimableAmountService.getClaimableAmount(stream, calculatedAt); - claimableTotal += BigInt(claimable.claimableAmount); + const withdrawn = BigInt(stream.withdrawnAmount); + totalStreamedInBigInt += withdrawn; + + if (stream.isActive) { + activeIncomingCount++; + const claimable = claimableAmountService.getClaimableAmount(stream, calculatedAt); + const claimableAmount = BigInt(claimable.claimableAmount); + totalStreamedInBigInt += claimableAmount; + claimableTotal += claimableAmount; + } } + const totalStreamedOut = totalStreamedOutBigInt.toString(); + const totalStreamedIn = totalStreamedInBigInt.toString(); + const summary: UserStreamSummary = { address, totalStreamsCreated, diff --git a/backend/tests/integration/streams.test.ts b/backend/tests/integration/streams.test.ts index f5fb4b89..f2793198 100644 --- a/backend/tests/integration/streams.test.ts +++ b/backend/tests/integration/streams.test.ts @@ -11,7 +11,7 @@ import { EventEmitter } from 'node:events'; // ─── Mocks (must be hoisted before real imports) ────────────────────────────── -const mockSseService = { +const mockSseService = vi.hoisted(() => ({ broadcastToStream: vi.fn(), broadcastToUser: vi.fn(), addClient: vi.fn(), @@ -23,7 +23,7 @@ const mockSseService = { checkCapacity: vi.fn().mockReturnValue({ allowed: true }), isShuttingDown: vi.fn().mockReturnValue(false), initRedisSubscription: vi.fn().mockResolvedValue(undefined), -}; +})); vi.mock('../../src/services/sse.service.js', () => ({ sseService: mockSseService, @@ -37,7 +37,7 @@ vi.mock('../../src/lib/redis.js', () => ({ })); // Prisma mock – set up base shape; individual tests will override per-method -const mockPrisma = { +const mockPrisma = vi.hoisted(() => ({ stream: { upsert: vi.fn(), findMany: vi.fn().mockResolvedValue([]), @@ -51,7 +51,7 @@ const mockPrisma = { }, $queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1n }]), $disconnect: vi.fn(), -}; +})); vi.mock('../../src/lib/prisma.js', () => ({ default: mockPrisma, diff --git a/backend/tests/stream.test.ts b/backend/tests/stream.test.ts index 48b8a606..8297ec80 100644 --- a/backend/tests/stream.test.ts +++ b/backend/tests/stream.test.ts @@ -9,6 +9,7 @@ vi.mock('../src/lib/prisma.js', () => ({ upsert: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), + count: vi.fn(), }, $queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1n }]), $disconnect: vi.fn(), @@ -18,6 +19,7 @@ vi.mock('../src/lib/prisma.js', () => ({ upsert: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), + count: vi.fn(), }, $queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1n }]), $disconnect: vi.fn(), diff --git a/frontend/src/__tests__/utils.test.ts b/frontend/src/__tests__/utils.test.ts index 60a5a5d1..0028decc 100644 --- a/frontend/src/__tests__/utils.test.ts +++ b/frontend/src/__tests__/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { convertArrayToCSV, downloadCSV } from '../utils/csvExport'; +import { convertArrayToCSV } from '../utils/csvExport'; import { isValidStellarPublicKey } from '../lib/stellar'; // ─── Amount / formatting utilities ─────────────────────────────────────────── @@ -103,7 +103,6 @@ describe('hasValidPrecision', () => { // ─── isValidStellarPublicKey ────────────────────────────────────────────────── describe('isValidStellarPublicKey (recipient validation)', () => { - const VALID_KEY = 'GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA'; it('accepts a valid G-prefixed Ed25519 public key', () => { // Use a real randomly-generated testnet key diff --git a/frontend/src/app/activity/page.tsx b/frontend/src/app/activity/page.tsx index 289bfecf..39a9abbd 100644 --- a/frontend/src/app/activity/page.tsx +++ b/frontend/src/app/activity/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { useWallet } from '@/context/wallet-context'; import { BackendStreamEvent } from '@/lib/api-types'; import { fetchUserEvents } from '@/lib/dashboard'; @@ -21,7 +21,7 @@ export default function ActivityPage() { if (session?.publicKey) { loadEvents(); } - }, [session?.publicKey]); + }, [session?.publicKey, loadEvents]); useEffect(() => { if (activeFilter === 'All') { @@ -31,7 +31,7 @@ export default function ActivityPage() { } }, [activeFilter, events]); - const loadEvents = async () => { + const loadEvents = useCallback(async () => { if (!session?.publicKey) return; setIsLoading(true); try { @@ -43,7 +43,7 @@ export default function ActivityPage() { } finally { setIsLoading(false); } - }; + }, [session?.publicKey]); const handleExportCSV = () => { const csvData = filteredEvents.map(event => ({ diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 62af66ba..a6a9983d 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -6,9 +6,6 @@ import "./globals.css"; import { WalletProvider } from "@/context/wallet-context"; import { Toaster } from "react-hot-toast"; import { ThemeProvider } from "@/context/theme-provider"; -import { Banner } from "@/components/ui/Banner"; -import bannerConfig from "@/lib/banner.config"; -import Link from "next/link"; import { Navbar } from "@/components/Navbar"; const sora = Sora({ diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 0f66854f..5414bcdd 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Copy, Check, LogOut, Moon, Sun, Bell, Globe } from "lucide-react"; import { useWallet } from "@/context/wallet-context"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { shortenPublicKey, formatNetwork } from "@/lib/wallet"; +import { formatNetwork } from "@/lib/wallet"; import toast from "react-hot-toast"; type DisplayCurrency = "USD" | "XLM" | "USDC"; diff --git a/frontend/src/app/streams/[id]/page.tsx b/frontend/src/app/streams/[id]/page.tsx index 657d75ea..05e88456 100644 --- a/frontend/src/app/streams/[id]/page.tsx +++ b/frontend/src/app/streams/[id]/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import React, { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; import LiveCounter from "@/components/Livecounter"; import ProgressBar from "@/components/Progressbar"; import { Button } from "@/components/ui/Button"; @@ -14,7 +14,8 @@ import { topUpStream, toSorobanErrorMessage, } from "@/lib/soroban"; -import type { WalletSession } from "@/lib/wallet"; +import { fromStroops, toStroops, formatRate, hasValidPrecision } from "@/utils/amount"; + interface StreamDetail { id: string; @@ -34,7 +35,6 @@ interface StreamDetail { export default function StreamDetailsPage() { const params = useParams(); - const router = useRouter(); const streamId = params.id as string; const { session, isHydrated } = useWallet(); @@ -47,7 +47,7 @@ export default function StreamDetailsPage() { const [showTopUp, setShowTopUp] = useState(false); // SSE integration for real-time stream updates - const { events: streamEvents, connected, reconnecting } = useStreamEvents({ + const { events: streamEvents } = useStreamEvents({ streamIds: [streamId], autoReconnect: true, }); @@ -150,15 +150,15 @@ export default function StreamDetailsPage() { return; } - if (!topUpAmount || parseFloat(topUpAmount) <= 0) { - toast.error("Please enter a valid amount"); + if (!topUpAmount || !hasValidPrecision(topUpAmount, 7) || parseFloat(topUpAmount) <= 0) { + toast.error("Please enter a valid amount (max 7 decimal places)"); return; } try { await topUpStream(session, { streamId: BigInt(streamId), - amount: BigInt(parseFloat(topUpAmount) * 1e7), // Convert to stroops + amount: toStroops(topUpAmount, 7), }); toast.success("Stream topped up successfully!"); setShowTopUp(false); @@ -190,8 +190,8 @@ export default function StreamDetailsPage() { ); } - const deposited = parseFloat(stream.depositedAmount) / 1e7; - const withdrawn = parseFloat(stream.withdrawnAmount) / 1e7; + const deposited = parseFloat(fromStroops(BigInt(stream.depositedAmount), 7)); + const withdrawn = parseFloat(fromStroops(BigInt(stream.withdrawnAmount), 7)); const claimable = deposited - withdrawn; const percentage = Math.round((withdrawn / deposited) * 100); @@ -248,7 +248,7 @@ export default function StreamDetailsPage() {

- Rate: {(parseFloat(stream.ratePerSecond) / 1e7).toFixed(7)} / sec + Rate: {formatRate(BigInt(stream.ratePerSecond), 7)}

Started: {new Date(stream.startTime * 1000).toLocaleDateString()} @@ -312,7 +312,7 @@ export default function StreamDetailsPage() { type="number" placeholder="Amount" value={topUpAmount} - onChange={(e) => setTopUpAmount(e.target.value)} + onChange={(e: React.ChangeEvent) => setTopUpAmount(e.target.value)} style={{ padding: "0.5rem", borderRadius: "0.25rem", diff --git a/frontend/src/app/streams/create/page.tsx b/frontend/src/app/streams/create/page.tsx index 7c418efe..8901cd2b 100644 --- a/frontend/src/app/streams/create/page.tsx +++ b/frontend/src/app/streams/create/page.tsx @@ -8,6 +8,7 @@ import { getTokenAddress, toSorobanErrorMessage } from "@/lib/soroban"; +import { hasValidPrecision, formatRate, toStroops } from "@/utils/amount"; import { toast } from "react-hot-toast"; import { useRouter } from "next/navigation"; import Link from "next/link"; @@ -101,7 +102,7 @@ export default function CreateStreamPage() { placeholder="G..." className="w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors" value={formData.recipient} - onChange={(e) => setFormData({ ...formData, recipient: e.target.value })} + onChange={(e: React.ChangeEvent) => setFormData({ ...formData, recipient: e.target.value })} required />

@@ -114,7 +115,7 @@ export default function CreateStreamPage() {