Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions backend/src/controllers/stream.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions backend/tests/integration/streams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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,
Expand All @@ -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([]),
Expand All @@ -51,7 +51,7 @@ const mockPrisma = {
},
$queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1n }]),
$disconnect: vi.fn(),
};
}));

vi.mock('../../src/lib/prisma.js', () => ({
default: mockPrisma,
Expand Down
2 changes: 2 additions & 0 deletions backend/tests/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
upsert: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
count: vi.fn(),
},
$queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1n }]),
$disconnect: vi.fn(),
Expand All @@ -18,6 +19,7 @@
upsert: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
count: vi.fn(),
},
$queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1n }]),
$disconnect: vi.fn(),
Expand Down Expand Up @@ -112,7 +114,7 @@
.set('Accept', 'application/json');

expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);

Check failure on line 117 in backend/tests/stream.test.ts

View workflow job for this annotation

GitHub Actions / Backend npm test

tests/stream.test.ts > GET /v1/streams > should return 200 with list of streams

AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ tests/stream.test.ts:117:42
});
});

Expand Down Expand Up @@ -212,7 +214,7 @@
const response = await request(app).get(`/v1/users/${address}/summary`);

expect(response.status).toBe(200);
expect(response.body).toMatchObject({

Check failure on line 217 in backend/tests/stream.test.ts

View workflow job for this annotation

GitHub Actions / Backend npm test

tests/stream.test.ts > GET /v1/users/:address/summary > returns accurate outgoing/incoming aggregates and claimable sum

AssertionError: expected { …(7) } to match object { …(7) } - Expected + Received Object { "activeIncomingCount": 1, "activeOutgoingCount": 1, "address": "GACCURATE00000000000000000000000000000000000000000000000000", "currentClaimable": "900", - "totalStreamedIn": "150", - "totalStreamedOut": "50", + "totalStreamedIn": "1000", + "totalStreamedOut": "120", "totalStreamsCreated": 2, } ❯ tests/stream.test.ts:217:27
address,
totalStreamsCreated: 2,
totalStreamedOut: '50',
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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 ───────────────────────────────────────────
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/app/activity/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,7 +21,7 @@ export default function ActivityPage() {
if (session?.publicKey) {
loadEvents();
}
}, [session?.publicKey]);
}, [session?.publicKey, loadEvents]);

useEffect(() => {
if (activeFilter === 'All') {
Expand 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 {
Expand All @@ -43,7 +43,7 @@ export default function ActivityPage() {
} finally {
setIsLoading(false);
}
};
}, [session?.publicKey]);

const handleExportCSV = () => {
const csvData = filteredEvents.map(event => ({
Expand Down
3 changes: 0 additions & 3 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
24 changes: 12 additions & 12 deletions frontend/src/app/streams/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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();

Expand All @@ -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,
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -248,7 +248,7 @@ export default function StreamDetailsPage() {
</div>
<div style={{ textAlign: "right" }}>
<p style={{ margin: "0.2rem 0", fontSize: "0.9rem" }}>
Rate: {(parseFloat(stream.ratePerSecond) / 1e7).toFixed(7)} / sec
Rate: {formatRate(BigInt(stream.ratePerSecond), 7)}
</p>
<p style={{ margin: "0.2rem 0", fontSize: "0.9rem" }}>
Started: {new Date(stream.startTime * 1000).toLocaleDateString()}
Expand Down Expand Up @@ -312,7 +312,7 @@ export default function StreamDetailsPage() {
type="number"
placeholder="Amount"
value={topUpAmount}
onChange={(e) => setTopUpAmount(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTopUpAmount(e.target.value)}
style={{
padding: "0.5rem",
borderRadius: "0.25rem",
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/app/streams/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HTMLInputElement>) => setFormData({ ...formData, recipient: e.target.value })}
required
/>
</div>
Expand All @@ -114,7 +115,7 @@ export default function CreateStreamPage() {
<select
className="w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors appearance-none"
value={formData.token}
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFormData({ ...formData, token: e.target.value })}
>
<option value="XLM">XLM</option>
<option value="USDC">USDC</option>
Expand All @@ -130,7 +131,12 @@ export default function CreateStreamPage() {
placeholder="0.00"
className="w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors"
value={formData.amount}
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (hasValidPrecision(val, 7)) {
setFormData({ ...formData, amount: val });
}
}}
required
/>
</div>
Expand All @@ -145,7 +151,7 @@ export default function CreateStreamPage() {
placeholder="30"
className="w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors"
value={formData.duration}
onChange={(e) => setFormData({ ...formData, duration: e.target.value })}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFormData({ ...formData, duration: e.target.value })}
required
/>
</div>
Expand All @@ -155,8 +161,8 @@ export default function CreateStreamPage() {
<span className="text-slate-400">Streaming Rate</span>
<span className="font-mono font-medium text-accent">
{formData.amount && formData.duration
? (Number(formData.amount) / (Number(formData.duration) * 86400)).toFixed(8)
: "0.00000000"} {formData.token}/sec
? formatRate(toStroops(formData.amount, 7) / toDurationSeconds(formData.duration, "days"), 7, formData.token)
: "0.00000000 " + formData.token + "/sec"}
</span>
</div>
<div className="flex justify-between items-center text-sm">
Expand Down
9 changes: 3 additions & 6 deletions frontend/src/app/streams/streams/[streamId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,17 @@ import {
toSorobanErrorMessage,
} from "@/lib/soroban";
import { shortenPublicKey } from "@/lib/wallet";
import { fromStroops } from "@/utils/amount";

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/v1";
const TOKEN_DECIMALS = 1e7;

interface StreamDetailsPageProps {
params: {
streamId: string;
};
}

function toDisplayAmount(baseUnits: string): number {
const parsed = Number(baseUnits);
if (!Number.isFinite(parsed)) return 0;
return parsed / TOKEN_DECIMALS;
return Number(fromStroops(BigInt(baseUnits), 7));
}

function formatUnixTimestamp(timestamp: number): string {
Expand Down Expand Up @@ -307,7 +304,7 @@ export default function StreamDetailsPage({ params }: StreamDetailsPageProps) {
<td>{event.eventType}</td>
<td>
{event.amount
? `${toDisplayAmount(event.amount).toFixed(2)} ${tokenSymbol}`
? `${fromStroops(BigInt(event.amount), 7)} ${tokenSymbol}`
: "-"}
</td>
<td>{event.ledgerSequence}</td>
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ const Dashboard: React.FC = () => {
if (activeTab === 'activity' && session?.publicKey) {
loadEvents();
}
}, [activeTab, session?.publicKey]);
}, [activeTab, session?.publicKey, loadEvents]);

const loadEvents = async () => {
const loadEvents = React.useCallback(async () => {
if (!session?.publicKey) return;
setIsLoadingEvents(true);
try {
Expand All @@ -50,7 +50,7 @@ const Dashboard: React.FC = () => {
} finally {
setIsLoadingEvents(false);
}
};
}, [session?.publicKey]);

const handleExport = () => {
downloadCSV(mockStreams, 'flowfi-stream-history.csv');
Expand Down
Loading
Loading