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() {