From cd1e7790241c82eb7066d22060e777cdff490053 Mon Sep 17 00:00:00 2001 From: Emelie-Dev Date: Tue, 30 Jun 2026 02:03:10 +0100 Subject: [PATCH 01/26] Add stream POST negative tests --- backend/src/controllers/stream.controller.ts | 25 +++++- backend/tests/stream.controller.test.ts | 22 +++++ backend/tests/stream.test.ts | 92 ++++++++++++++++---- 3 files changed, 120 insertions(+), 19 deletions(-) diff --git a/backend/src/controllers/stream.controller.ts b/backend/src/controllers/stream.controller.ts index 5476fb6e..1dff0231 100644 --- a/backend/src/controllers/stream.controller.ts +++ b/backend/src/controllers/stream.controller.ts @@ -67,9 +67,26 @@ function sumStringI128(values: string[]): string { export const createStream = async (req: Request, res: Response) => { try { const { streamId, sender, recipient, tokenAddress, ratePerSecond, depositedAmount, startTime } = req.body; + const callerAddress = (req as AuthenticatedRequest).user?.publicKey; - const parsedStreamId = Number.parseInt(streamId, 10); - const parsedStartTime = Number.parseInt(startTime, 10); + if (callerAddress && callerAddress !== sender) { + return res.status(403).json({ + error: 'Forbidden', + message: 'Only the stream sender can create or reactivate the stream' + }); + } + + if ( + streamId == null + || startTime == null + || ratePerSecond == null + || depositedAmount == null + ) { + return res.status(400).json({ error: 'Missing required numeric fields in request body' }); + } + + const parsedStreamId = Number.parseInt(String(streamId), 10); + const parsedStartTime = Number.parseInt(String(startTime), 10); const parsedRatePerSecond = BigInt(ratePerSecond); const parsedDepositedAmount = BigInt(depositedAmount); @@ -113,8 +130,8 @@ export const createStream = async (req: Request, res: Response) => { return res.status(201).json(stream); } catch (error) { - if (error instanceof RangeError) { - logger.error('Range error in createStream:', 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); diff --git a/backend/tests/stream.controller.test.ts b/backend/tests/stream.controller.test.ts index ef37fe1b..48b5ac64 100644 --- a/backend/tests/stream.controller.test.ts +++ b/backend/tests/stream.controller.test.ts @@ -61,6 +61,9 @@ describe('Stream Controller', () => { }, query: {}, params: {}, + user: { + publicKey: 'GSENDER', + }, }; res = { status: vi.fn().mockReturnThis(), @@ -89,6 +92,25 @@ describe('Stream Controller', () => { await createStream(req as Request, res as Response); expect(res.status).toHaveBeenCalledWith(400); }); + + it('should return 400 for malformed numeric fields', async () => { + req.body.ratePerSecond = 'abc'; + await createStream(req as Request, res as Response); + expect(res.status).toHaveBeenCalledWith(400); + }); + + 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); + }); + + it('should return 403 when caller does not match sender on upsert', async () => { + (req as any).user = { publicKey: 'GNOTSENDER' }; + await createStream(req as Request, res as Response); + expect(res.status).toHaveBeenCalledWith(403); + expect(prisma.stream.upsert).not.toHaveBeenCalled(); + }); }); describe('listStreams', () => { diff --git a/backend/tests/stream.test.ts b/backend/tests/stream.test.ts index f5d9903a..cad05ac1 100644 --- a/backend/tests/stream.test.ts +++ b/backend/tests/stream.test.ts @@ -59,7 +59,7 @@ describe('POST /v1/streams', () => { const mockStream = { id: 'uuid-123', streamId: 1, - sender: 'GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA', + sender: 'GTEST_USER_PUBLIC_KEY', recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD', tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE', ratePerSecond: '100', @@ -80,7 +80,7 @@ describe('POST /v1/streams', () => { const validData = { streamId: '1', - sender: 'GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA', + sender: 'GTEST_USER_PUBLIC_KEY', recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD', tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE', ratePerSecond: '100', @@ -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') @@ -108,7 +170,7 @@ describe('POST /v1/streams', () => { const validData = { streamId: '2', - sender: 'GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA', + sender: 'GTEST_USER_PUBLIC_KEY', recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD', tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE', ratePerSecond: '100', @@ -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([]); From 3fbf6eed5fe60ae77cc0d68733a6c632cb8dc9de Mon Sep 17 00:00:00 2001 From: Emelie-Dev Date: Wed, 1 Jul 2026 00:06:59 +0100 Subject: [PATCH 02/26] Fixed issues --- backend/Dockerfile | 2 + backend/tests/stream.controller.test.ts | 182 +++-- frontend/src/app/streams/create/page.tsx | 484 +++++++------ .../components/dashboard/dashboard-view.tsx | 680 +++++++++++++++--- 4 files changed, 948 insertions(+), 400 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 671fb1d2..6ed44233 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -22,6 +22,8 @@ RUN npm install --omit=dev COPY --from=builder /app/dist ./dist COPY --from=builder /app/src/generated ./src/generated +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/prisma.config.ts ./prisma.config.ts EXPOSE 3001 diff --git a/backend/tests/stream.controller.test.ts b/backend/tests/stream.controller.test.ts index 48b5ac64..0bf669f6 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,18 +64,18 @@ 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', + publicKey: "GSENDER", }, }; res = { @@ -71,8 +84,8 @@ describe('Stream Controller', () => { }; }); - describe('createStream', () => { - it('should create a stream successfully', async () => { + describe("createStream", () => { + it("should create a stream successfully", async () => { (prisma.stream.upsert as any).mockResolvedValue({ streamId: 123 }); await createStream(req as Request, res as Response); @@ -81,54 +94,60 @@ describe('Stream Controller', () => { expect(prisma.stream.upsert).toHaveBeenCalled(); }); - it('should return 400 for invalid streamId', async () => { - req.body.streamId = 'abc'; + it("should return 400 for invalid streamId", async () => { + req.body.streamId = "abc"; await createStream(req as Request, res as Response); expect(res.status).toHaveBeenCalledWith(400); }); - it('should return 400 for non-positive ratePerSecond', async () => { - req.body.ratePerSecond = '0'; + it("should return 400 for non-positive ratePerSecond", async () => { + req.body.ratePerSecond = "0"; await createStream(req as Request, res as Response); expect(res.status).toHaveBeenCalledWith(400); }); - it('should return 400 for malformed numeric fields', async () => { - req.body.ratePerSecond = 'abc'; + it("should return 400 for malformed numeric fields", async () => { + req.body.ratePerSecond = "abc"; await createStream(req as Request, res as Response); expect(res.status).toHaveBeenCalledWith(400); }); - it('should return 400 when a required numeric field is missing', async () => { + 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); }); - it('should return 403 when caller does not match sender on upsert', async () => { - (req as any).user = { publicKey: 'GNOTSENDER' }; + it("should return 403 when caller does not match sender on upsert", async () => { + (req as any).user = { publicKey: "GNOTSENDER" }; await createStream(req as Request, res as Response); expect(res.status).toHaveBeenCalledWith(403); expect(prisma.stream.upsert).not.toHaveBeenCalled(); }); }); - 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); @@ -136,38 +155,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); @@ -175,14 +216,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/frontend/src/app/streams/create/page.tsx b/frontend/src/app/streams/create/page.tsx index f1c7cfd9..12ed9ff0 100644 --- a/frontend/src/app/streams/create/page.tsx +++ b/frontend/src/app/streams/create/page.tsx @@ -8,263 +8,301 @@ export const metadata: Metadata = { export default function CreateStreamPage() { return ; -import React, { useState } from "react"; -import { - createStream, - toBaseUnits, - toDurationSeconds, - getTokenAddress, - toSorobanErrorMessage, - TOKEN_ADDRESSES -} from "@/lib/soroban"; -import { hasValidPrecision, validateAmountInput } from "@/utils/amount"; -import { isValidStellarPublicKey } from "@/lib/stellar"; -import { toast } from "react-hot-toast"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; -import { useWallet } from "@/context/wallet-context"; + import React, { useState } from "react"; + import { + createStream, + toBaseUnits, + toDurationSeconds, + getTokenAddress, + toSorobanErrorMessage, + TOKEN_ADDRESSES, + } from "@/lib/soroban"; + import { hasValidPrecision, validateAmountInput } from "@/utils/amount"; + import { isValidStellarPublicKey } from "@/lib/stellar"; + import { toast } from "react-hot-toast"; + import { useRouter } from "next/navigation"; + import Link from "next/link"; + import { ArrowLeft } from "lucide-react"; + import { useWallet } from "@/context/wallet-context"; -const TOKEN_DECIMALS = 7; + const TOKEN_DECIMALS = 7; -export default function CreateStreamPage() { - const { status, session } = useWallet(); - const router = useRouter(); - const [nowTimestamp] = useState(() => Date.now()); - const [loading, setLoading] = useState(false); - const [txState, setTxState] = useState<"idle" | "signing" | "submitted" | "confirming">("idle"); - const [formData, setFormData] = useState({ - recipient: "", - token: "XLM", - amount: "", - duration: "30", // days - }); + export default function CreateStreamPage() { + const { status, session } = useWallet(); + const router = useRouter(); + const [nowTimestamp] = useState(() => Date.now()); + const [loading, setLoading] = useState(false); + const [txState, setTxState] = useState< + "idle" | "signing" | "submitted" | "confirming" + >("idle"); + const [formData, setFormData] = useState({ + recipient: "", + token: "XLM", + amount: "", + duration: "30", // days + }); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (status !== "connected" || !session) { - toast.error("Please connect your wallet first."); - return; - } + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (status !== "connected" || !session) { + toast.error("Please connect your wallet first."); + return; + } - // Validate recipient - if (!formData.recipient.trim()) { - toast.error("Recipient address is required"); - return; - } - if (!isValidStellarPublicKey(formData.recipient)) { - toast.error("Invalid Stellar public key format"); - return; - } + // Validate recipient + if (!formData.recipient.trim()) { + toast.error("Recipient address is required"); + return; + } + if (!isValidStellarPublicKey(formData.recipient)) { + toast.error("Invalid Stellar public key format"); + return; + } - // Validate amount - const validationError = validateAmountInput(formData.amount, TOKEN_DECIMALS); - if (validationError) { - toast.error(validationError); - return; - } + // Validate amount + const validationError = validateAmountInput( + formData.amount, + TOKEN_DECIMALS, + ); + if (validationError) { + toast.error(validationError); + return; + } - // Validate duration - const durationNum = parseFloat(formData.duration); - if (isNaN(durationNum) || durationNum <= 0) { - toast.error("Duration must be a positive number"); - return; - } + // Validate duration + const durationNum = parseFloat(formData.duration); + if (isNaN(durationNum) || durationNum <= 0) { + toast.error("Duration must be a positive number"); + return; + } - setLoading(true); - setTxState("signing"); + setLoading(true); + setTxState("signing"); - try { - const amountBigInt = toBaseUnits(formData.amount); - const durationBigInt = toDurationSeconds(formData.duration, "days"); - const tokenAddress = getTokenAddress(formData.token); + try { + const amountBigInt = toBaseUnits(formData.amount); + const durationBigInt = toDurationSeconds(formData.duration, "days"); + const tokenAddress = getTokenAddress(formData.token); - const result = await createStream(session, { - recipient: formData.recipient, - tokenAddress, - amount: amountBigInt, - durationSeconds: durationBigInt, - }); + const result = await createStream(session, { + recipient: formData.recipient, + tokenAddress, + amount: amountBigInt, + durationSeconds: durationBigInt, + }); - if (result.success) { - setTxState("confirming"); - toast.success("Stream created successfully!"); - // Small delay to allow indexer to catch up - setTimeout(() => { - router.push("/dashboard"); - }, 2000); + if (result.success) { + setTxState("confirming"); + toast.success("Stream created successfully!"); + // Small delay to allow indexer to catch up + setTimeout(() => { + router.push("/dashboard"); + }, 2000); + } + } catch (error) { + console.error("Stream creation failed:", error); + toast.error(toSorobanErrorMessage(error)); + } finally { + setLoading(false); + setTxState("idle"); } - } catch (error) { - console.error("Stream creation failed:", error); - toast.error(toSorobanErrorMessage(error)); - } finally { - setLoading(false); - setTxState("idle"); - } - }; + }; - const getButtonText = () => { - if (!loading) return "Start Streaming"; - switch (txState) { - case "signing": return "Confirm in Wallet..."; - case "submitted": return "Submitting to Network..."; - case "confirming": return "Finalizing Stream..."; - default: return "Processing..."; - } - }; + const getButtonText = () => { + if (!loading) return "Start Streaming"; + switch (txState) { + case "signing": + return "Confirm in Wallet..."; + case "submitted": + return "Submitting to Network..."; + case "confirming": + return "Finalizing Stream..."; + default: + return "Processing..."; + } + }; - // Inline validation feedback for the amount field. validateAmountInput - // returns an error message when invalid and null when valid. Only show it - // once the user has typed something — the empty case is handled on submit. - const amountError = formData.amount - ? validateAmountInput(formData.amount, TOKEN_DECIMALS) - : null; + // Inline validation feedback for the amount field. validateAmountInput + // returns an error message when invalid and null when valid. Only show it + // once the user has typed something — the empty case is handled on submit. + const amountError = formData.amount + ? validateAmountInput(formData.amount, TOKEN_DECIMALS) + : null; - const recipientError = formData.recipient - ? (!isValidStellarPublicKey(formData.recipient) ? "Invalid Stellar public key format" : null) - : null; + const recipientError = formData.recipient + ? !isValidStellarPublicKey(formData.recipient) + ? "Invalid Stellar public key format" + : null + : null; - const durationError = formData.duration - ? (isNaN(Number(formData.duration)) || Number(formData.duration) <= 0 - ? "Duration must be a positive number" - : null) - : null; + const durationError = formData.duration + ? isNaN(Number(formData.duration)) || Number(formData.duration) <= 0 + ? "Duration must be a positive number" + : null + : null; - return ( -
- - - Back to Dashboard - + return ( +
+ + + Back to Dashboard + -
-

Create New Stream

-

- Set up a real-time payment stream to any Stellar address. -

+
+

Create New Stream

+

+ Set up a real-time payment stream to any Stellar address. +

-
-
- - setFormData({ ...formData, recipient: e.target.value })} - required - /> - {recipientError && ( -

{recipientError}

- )} -
- -
+
- + + setFormData({ ...formData, recipient: e.target.value }) + } + required + /> + {recipientError && ( +

{recipientError}

+ )}
+ +
+
+ + +
+
+ + { + const newValue = e.target.value; + // Only allow valid number characters and check precision + if (newValue === "" || /^\d*\.?\d*$/.test(newValue)) { + if (hasValidPrecision(newValue, TOKEN_DECIMALS)) { + setFormData({ ...formData, amount: newValue }); + } + } + }} + required + /> + {amountError && ( +

{amountError}

+ )} +
+
+
{ - const newValue = e.target.value; - // Only allow valid number characters and check precision - if (newValue === '' || /^\d*\.?\d*$/.test(newValue)) { - if (hasValidPrecision(newValue, TOKEN_DECIMALS)) { - setFormData({ ...formData, amount: newValue }); - } - } - }} + value={formData.duration} + onChange={(e) => + setFormData({ ...formData, duration: e.target.value }) + } required /> - {amountError && ( -

{amountError}

+ {durationError && ( +

{durationError}

)}
-
- -
- - setFormData({ ...formData, duration: e.target.value })} - required - /> - {durationError && ( -

{durationError}

- )} -
-
-
- Streaming Rate - - {formData.amount && formData.duration && Number(formData.duration) > 0 - ? (Number(formData.amount) / (Number(formData.duration) * 86400)).toFixed(8) - : "0.00000000"} {formData.token}/sec - +
+
+ Streaming Rate + + {formData.amount && + formData.duration && + Number(formData.duration) > 0 + ? ( + Number(formData.amount) / + (Number(formData.duration) * 86400) + ).toFixed(8) + : "0.00000000"}{" "} + {formData.token}/sec + +
+
+ Estimated End Date + + {formData.duration && Number(formData.duration) > 0 + ? new Date( + nowTimestamp + Number(formData.duration) * 86400000, + ).toLocaleDateString() + : "—"} + +
-
- Estimated End Date - - {formData.duration && Number(formData.duration) > 0 - ? new Date(nowTimestamp + Number(formData.duration) * 86400000).toLocaleDateString() - : "—"} - -
-
- - - {status !== "connected" && ( -

- Please connect your wallet to create a stream. -

- )} - + + + {status !== "connected" && ( +

+ Please connect your wallet to create a stream. +

+ )} + +
-
- ); + ); + } } diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx index 9247431c..fb7dbcd5 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -108,21 +108,23 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ /** Shimmer card used as a placeholder while data loads */ function SkeletonCard({ className = "" }: { className?: string }) { return ( -