Skip to content
Open
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
6 changes: 3 additions & 3 deletions backend/src/controllers/stream.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export const listStreams = async (req: Request, res: Response) => {
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' ? Math.max(0, Number.parseInt(offset, 10) || 0) : 0;

// Validate sort field
const validSortFields = ['createdAt', 'startTime', 'lastUpdateTime', 'depositedAmount', 'endTime'];
Expand Down Expand Up @@ -295,7 +295,7 @@ export const getStreamEvents = async (req: Request, res: Response) => {

let offset = 0;
if (rawOffset && typeof rawOffset === 'string') {
offset = Number.parseInt(rawOffset, 10) || 0;
offset = Math.max(0, Number.parseInt(rawOffset, 10) || 0);
} else if (rawPage && typeof rawPage === 'string' && !cursor) {
const page = Number.parseInt(rawPage, 10) || 1;
offset = Math.max(0, (page - 1) * limit);
Expand Down Expand Up @@ -564,7 +564,7 @@ export const topUpStreamHandler = async (req: Request, res: Response) => {
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(400).json({ error: 'Failed to top up stream on chain', message: error.message ?? 'Unknown error' });
}
};

Expand Down
2 changes: 1 addition & 1 deletion backend/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const getUserEvents = async (req: Request, res: Response, next: NextFunct
rawLimit && typeof rawLimit === 'string' ? (Number.parseInt(rawLimit, 10) || DEFAULT_EVENTS_PAGE_SIZE) : DEFAULT_EVENTS_PAGE_SIZE,
MAX_EVENTS_PAGE_SIZE
);
const offset = rawOffset && typeof rawOffset === 'string' ? (Number.parseInt(rawOffset, 10) || 0) : 0;
const offset = rawOffset && typeof rawOffset === 'string' ? Math.max(0, Number.parseInt(rawOffset, 10) || 0) : 0;

const whereClause = {
stream: {
Expand Down
8 changes: 0 additions & 8 deletions backend/src/routes/v1/streams/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import { Router } from 'express';
import { requireAuth } from '../../../middleware/auth.js';
import { withdrawHandler } from './withdraw.js';
import oldStreamRoutes from '../stream.routes.js';

const router = Router();

// Mount the old routes first
router.use('/', oldStreamRoutes);

/**
* Override/Add POST /api/v1/streams/:streamId/withdraw
*/
router.post('/:streamId/withdraw', requireAuth, withdrawHandler as any);

export default router;
259 changes: 0 additions & 259 deletions frontend/src/app/streams/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,263 +8,4 @@ export const metadata: Metadata = {

export default function CreateStreamPage() {
return <CreateStreamContent />;
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;

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;
}

// 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 duration
const durationNum = parseFloat(formData.duration);
if (isNaN(durationNum) || durationNum <= 0) {
toast.error("Duration must be a positive number");
return;
}

setLoading(true);
setTxState("signing");

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,
});

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");
}
};

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;

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;

return (
<div className="container mx-auto max-w-2xl px-4 py-12">
<Link
href="/dashboard"
className="mb-8 inline-flex items-center text-sm font-medium text-slate-400 hover:text-white transition-colors"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>

<div className="glass-card rounded-3xl border-slate-800 p-8">
<h1 className="mb-2 text-3xl font-bold">Create New Stream</h1>
<p className="mb-8 text-slate-400">
Set up a real-time payment stream to any Stellar address.
</p>

<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">
Recipient Address
</label>
<input
type="text"
placeholder="G..."
className={`w-full rounded-xl border ${
recipientError ? "border-red-500 focus:border-red-500" : "border-slate-800 focus:border-accent"
} bg-slate-900/50 p-4 outline-none transition-colors`}
value={formData.recipient}
onChange={(e) => setFormData({ ...formData, recipient: e.target.value })}
required
/>
{recipientError && (
<p className="text-xs text-red-400 mt-1">{recipientError}</p>
)}
</div>

<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">
Token
</label>
<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 })}
>
{Object.keys(TOKEN_ADDRESSES).map((symbol) => (
<option key={symbol} value={symbol}>
{symbol}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">
Total Amount
</label>
<input
type="text"
inputMode="decimal"
placeholder="0.00"
className={`w-full rounded-xl border ${
amountError ? "border-red-500 focus:border-red-500" : "border-slate-800 focus:border-accent"
} bg-slate-900/50 p-4 outline-none transition-colors`}
value={formData.amount}
onChange={(e) => {
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 && (
<p className="text-xs text-red-400 mt-1">{amountError}</p>
)}
</div>
</div>

<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">
Duration (Days)
</label>
<input
type="number"
placeholder="30"
className={`w-full rounded-xl border ${
durationError ? "border-red-500 focus:border-red-500" : "border-slate-800 focus:border-accent"
} bg-slate-900/50 p-4 outline-none transition-colors`}
value={formData.duration}
onChange={(e) => setFormData({ ...formData, duration: e.target.value })}
required
/>
{durationError && (
<p className="text-xs text-red-400 mt-1">{durationError}</p>
)}
</div>

<div className="rounded-2xl bg-accent/5 p-6 space-y-4">
<div className="flex justify-between items-center text-sm">
<span className="text-slate-400">Streaming Rate</span>
<span className="font-mono font-medium text-accent">
{formData.amount && formData.duration && Number(formData.duration) > 0
? (Number(formData.amount) / (Number(formData.duration) * 86400)).toFixed(8)
: "0.00000000"} {formData.token}/sec
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-slate-400">Estimated End Date</span>
<span className="font-medium">
{formData.duration && Number(formData.duration) > 0
? new Date(nowTimestamp + Number(formData.duration) * 86400000).toLocaleDateString()
: "—"}
</span>
</div>
</div>

<button
type="submit"
disabled={loading || status !== "connected" || !!amountError || !!recipientError || !!durationError}
className="w-full rounded-xl bg-accent py-4 text-lg font-bold text-background transition-all hover:opacity-90 disabled:opacity-50 active:scale-[0.98]"
>
{getButtonText()}
</button>

{status !== "connected" && (
<p className="text-center text-sm text-red-400">
Please connect your wallet to create a stream.
</p>
)}
</form>
</div>
</div>
);
}
7 changes: 2 additions & 5 deletions frontend/src/components/dashboard/dashboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,8 @@ const SIDEBAR_ITEMS: SidebarItem[] = [
/** Shimmer card used as a placeholder while data loads */
function SkeletonCard({ className = "" }: { className?: string }) {
return (
<Skeleton
className={`rounded-2xl ${className}`}
aria-hidden="true"
/>
>
<div className={`relative overflow-hidden rounded-2xl ${className}`}>
<Skeleton className="absolute inset-0" aria-hidden="true" />
{/* shimmer sweep */}
<div className="absolute inset-0 -translate-x-full animate-shimmer bg-gradient-to-r from-transparent via-white/10 to-transparent" />
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/hooks/useIncomingStreams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function useWithdrawIncomingStream(

return { previousStreams, expectedWithdrawn };
},
onSuccess: async (result, stream, _variables, context) => {
onSuccess: async (result, stream, context) => {
if (publicKey) {
const targetWithdrawn = context?.expectedWithdrawn ?? stream.withdrawn;
// Start polling in the background without blocking the mutation
Expand Down
3 changes: 2 additions & 1 deletion frontend/vitest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
plugins: [react()],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
plugins: [react()] as any,
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
Expand Down
Loading