diff --git a/README.md b/README.md index d46c2ae..ef9fe28 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,96 @@ cd contracts cargo build --target wasm32-unknown-unknown --release ``` +## Deployment + +### Contract Deployment + +The FlowFi smart contracts can be deployed to both testnet and mainnet using the automated deployment script. + +#### Prerequisites + +- Stellar CLI installed and configured +- Sufficient XLM in the deployment account for network fees +- Required environment variables set + +#### Environment Variables + +Before deploying, set the following environment variables: + +```bash +export STELLAR_SECRET_KEY="your_secret_key_here" +export ADMIN_ADDRESS="your_admin_address_here" +export TREASURY_ADDRESS="your_treasury_address_here" +export FEE_RATE_BPS="25" # 0.25% fee rate +``` + +#### Deploy to Testnet + +```bash +npx tsx scripts/deploy.ts --network testnet +``` + +#### Deploy to Mainnet + +```bash +npx tsx scripts/deploy.ts --network mainnet +``` + +#### Deployment Process + +The deployment script automates the following steps: + +1. **Build WASM**: Compiles the Rust contract to WebAssembly +2. **Optimize WASM**: Optimizes the WASM for deployment size +3. **Deploy Contract**: Deploys the contract to the specified network +4. **Initialize Contract**: Sets up admin, treasury, and fee rate parameters +5. **Save Deployment Info**: Stores contract details in `deployment-info.json` + +#### Deployment Information + +After successful deployment, contract details are saved to `deployment-info.json`: + +```json +{ + "testnet": { + "network": "testnet", + "contractId": "CD...ID", + "deployedAt": "2024-01-01T00:00:00.000Z", + "adminAddress": "G...ADMIN", + "treasuryAddress": "G...TREASURY", + "feeRateBps": 25, + "transactionHash": "TX...HASH" + }, + "mainnet": { + "network": "mainnet", + "contractId": "CD...ID", + "deployedAt": "2024-01-01T00:00:00.000Z", + "adminAddress": "G...ADMIN", + "treasuryAddress": "G...TREASURY", + "feeRateBps": 25, + "transactionHash": "TX...HASH" + }, + "lastUpdated": "2024-01-01T00:00:00.000Z" +} +``` + +#### Manual Deployment + +If you prefer to deploy manually, you can use the Stellar CLI directly: + +```bash +# Build and optimize +cd contracts +cargo build --target wasm32-unknown-unknown --release +stellar contract optimize --wasm target/wasm32-unknown-unknown/release/stream_contract.wasm + +# Deploy +stellar contract deploy --wasm target/wasm32-unknown-unknown/release/stream_contract.optimized.wasm --source YOUR_SECRET_KEY --network https://soroban-testnet.stellar.org + +# Initialize +stellar contract invoke --id CONTRACT_ID --source YOUR_SECRET_KEY --network https://soroban-testnet.stellar.org initialize --admin ADMIN_ADDRESS --treasury TREASURY_ADDRESS --fee_rate_bps 25 +``` + ## API Documentation The FlowFi backend API uses URL-based versioning. All endpoints are prefixed with a version (e.g., `/v1/streams`). diff --git a/backend/src/controllers/stream.controller.ts b/backend/src/controllers/stream.controller.ts index b10e8b1..d3b7886 100644 --- a/backend/src/controllers/stream.controller.ts +++ b/backend/src/controllers/stream.controller.ts @@ -79,26 +79,95 @@ export const createStream = async (req: Request, res: Response) => { }; /** - * List streams by sender or recipient + * List streams by sender, recipient, status, token with sorting and pagination */ export const listStreams = async (req: Request, res: Response) => { try { - const { sender, recipient } = req.query; + const { + sender, + recipient, + status, + token, + sort = 'createdAt', + order = 'desc', + limit = '20', + offset = '0' + } = req.query; const where: any = {}; if (typeof sender === 'string') where.sender = sender; if (typeof recipient === 'string') where.recipient = recipient; + if (typeof token === 'string') where.tokenAddress = token; - const streams = await prisma.stream.findMany({ - where, - orderBy: { createdAt: 'desc' }, - include: { - senderUser: true, - recipientUser: true + // Handle status filtering + if (typeof status === 'string') { + const validStatuses = ['active', 'cancelled', 'completed', 'paused']; + if (!validStatuses.includes(status)) { + return res.status(400).json({ + error: 'Invalid status parameter', + message: `status must be one of: ${validStatuses.join(', ')}` + }); } - }); - return res.status(200).json(streams); + // Map status to database conditions + switch (status) { + case 'active': + where.isActive = true; + break; + case 'cancelled': + where.isActive = false; + // Additional check for cancelled events could be added here + break; + case 'completed': + where.isActive = false; + // Additional check for completed events could be added here + break; + case 'paused': + where.isActive = false; + // Additional check for paused events could be added here + break; + } + } + + // Validate and parse pagination parameters + const parsedLimit = Math.min( + typeof limit === 'string' ? (Number.parseInt(limit, 10) || 20) : 20, + 100 + ); + const parsedOffset = typeof offset === 'string' ? (Number.parseInt(offset, 10) || 0) : 0; + + // Validate sort field + const validSortFields = ['createdAt', 'startTime', 'lastUpdateTime', 'depositedAmount']; + const sortField = validSortFields.includes(typeof sort === 'string' ? sort : 'createdAt') + ? (sort as 'createdAt' | 'startTime' | 'lastUpdateTime' | 'depositedAmount') + : 'createdAt'; + + // Validate order + const sortOrder = order === 'asc' ? 'asc' : 'desc'; + + const [streams, total] = await Promise.all([ + prisma.stream.findMany({ + where, + orderBy: { [sortField]: sortOrder }, + take: parsedLimit, + skip: parsedOffset, + include: { + senderUser: true, + recipientUser: true + } + }), + prisma.stream.count({ where }) + ]); + + const hasMore = parsedOffset + streams.length < total; + + return res.status(200).json({ + data: streams, + total, + hasMore, + limit: parsedLimit, + offset: parsedOffset + }); } catch (error) { logger.error('Error listing streams:', error); return res.status(500).json({ error: 'Internal server error' }); @@ -302,7 +371,7 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => { */ export const getUserStreamSummary = async (req: Request, res: Response) => { try { - const address = (req.params.address ?? '').trim(); + const address = Array.isArray(req.params.address) ? req.params.address[0] : (req.params.address ?? '').trim(); if (!address) { return res.status(400).json({ error: 'Address is required' }); } @@ -339,10 +408,10 @@ export const getUserStreamSummary = async (req: Request, res: Response) => { ]); const totalStreamsCreated = outgoingStreams.length; - const totalStreamedOut = sumStringI128(outgoingStreams.map((stream) => stream.withdrawnAmount)); - const totalStreamedIn = sumStringI128(incomingStreams.map((stream) => stream.withdrawnAmount)); - const activeOutgoingCount = outgoingStreams.filter((stream) => stream.isActive).length; - const activeIncomingCount = incomingStreams.filter((stream) => stream.isActive).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 claimableTotal = 0n; diff --git a/backend/src/lib/redis.ts b/backend/src/lib/redis.ts index 6f748d9..f155090 100644 --- a/backend/src/lib/redis.ts +++ b/backend/src/lib/redis.ts @@ -1,18 +1,17 @@ -import type { Redis } from 'ioredis'; -import RedisClass from 'ioredis'; +const Redis = require('ioredis'); import logger from '../logger.js'; const REDIS_URL = process.env.REDIS_URL; -let _publisher: Redis | null = null; -let _subscriber: Redis | null = null; +let _publisher: typeof Redis | null = null; +let _subscriber: typeof Redis | null = null; let _available = false; -export function getPublisher(): Redis | null { +export function getPublisher(): typeof Redis | null { return _publisher; } -export function getSubscriber(): Redis | null { +export function getSubscriber(): typeof Redis | null { return _subscriber; } @@ -20,8 +19,8 @@ export function isRedisAvailable(): boolean { return _available; } -function makeClient(url: string): Redis { - return new RedisClass(url, { +function makeClient(url: string): typeof Redis { + return new Redis(url, { maxRetriesPerRequest: 3, retryStrategy: (times: number) => times > 3 ? null : Math.min(times * 200, 2000), diff --git a/backend/src/services/sse.service.ts b/backend/src/services/sse.service.ts index 00335d2..d2feea7 100644 --- a/backend/src/services/sse.service.ts +++ b/backend/src/services/sse.service.ts @@ -183,4 +183,5 @@ class SSEService { } } +export { SSEService }; export const sseService = new SSEService(); diff --git a/backend/src/workers/soroban-event-worker.ts b/backend/src/workers/soroban-event-worker.ts index 62392ab..96c38e0 100644 --- a/backend/src/workers/soroban-event-worker.ts +++ b/backend/src/workers/soroban-event-worker.ts @@ -133,7 +133,6 @@ export class SorobanEventWorker { logger.error('[SorobanWorker] Manual poll error:', err); } } - } // ─── Internal ────────────────────────────────────────────────────────────── diff --git a/backend/tests/sse.service.test.ts b/backend/tests/sse.service.test.ts index 58d34a8..f8928ff 100644 --- a/backend/tests/sse.service.test.ts +++ b/backend/tests/sse.service.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; -import { SSEService } from '../src/services/sse.service.js'; +import { SSEService, sseService } from '../src/services/sse.service.js'; function createMockResponse() { const emitter = new EventEmitter(); diff --git a/backend/tests/stream.test.ts b/backend/tests/stream.test.ts index e01749d..48b8a60 100644 --- a/backend/tests/stream.test.ts +++ b/backend/tests/stream.test.ts @@ -122,7 +122,7 @@ describe('GET /v1/users/:address/summary', () => { }); it('returns all-zero summary for addresses with no streams', async () => { - prisma.stream.findMany + vi.mocked(prisma.stream.findMany) .mockResolvedValueOnce([]) .mockResolvedValueOnce([]); @@ -142,29 +142,69 @@ describe('GET /v1/users/:address/summary', () => { }); it('returns accurate outgoing/incoming aggregates and claimable sum', async () => { - prisma.stream.findMany + vi.mocked(prisma.stream.findMany) .mockResolvedValueOnce([ - { withdrawnAmount: '30', isActive: true }, - { withdrawnAmount: '20', isActive: false }, + { + id: '1', + createdAt: new Date(), + updatedAt: new Date(), + streamId: 1, + sender: 'GSENDER', + recipient: 'GRECIPIENT', + tokenAddress: 'TOKEN', + ratePerSecond: '10', + depositedAmount: '100', + withdrawnAmount: '30', + startTime: 1000, + lastUpdateTime: 2000, + isActive: true + }, + { + id: '2', + createdAt: new Date(), + updatedAt: new Date(), + streamId: 2, + sender: 'GSENDER2', + recipient: 'GRECIPIENT2', + tokenAddress: 'TOKEN2', + ratePerSecond: '20', + depositedAmount: '200', + withdrawnAmount: '20', + startTime: 1000, + lastUpdateTime: 2000, + isActive: false + }, ]) .mockResolvedValueOnce([ { + id: '3', + createdAt: new Date(), + updatedAt: new Date(), streamId: 11, + sender: 'GSENDER3', + recipient: 'GRECIPIENT3', + tokenAddress: 'TOKEN3', ratePerSecond: '10', depositedAmount: '1000', withdrawnAmount: '100', + startTime: 1000, lastUpdateTime: 0, isActive: true, - updatedAt: new Date(), }, { + id: '4', + createdAt: new Date(), + updatedAt: new Date(), streamId: 12, - ratePerSecond: '1', - depositedAmount: '200', - withdrawnAmount: '50', + sender: 'GSENDER4', + recipient: 'GRECIPIENT4', + tokenAddress: 'TOKEN4', + ratePerSecond: '5', + depositedAmount: '500', + withdrawnAmount: '0', + startTime: 1000, lastUpdateTime: 0, isActive: false, - updatedAt: new Date(), }, ]); @@ -184,8 +224,22 @@ describe('GET /v1/users/:address/summary', () => { }); it('caches summary results for repeated requests within TTL', async () => { - prisma.stream.findMany - .mockResolvedValueOnce([{ withdrawnAmount: '1', isActive: true }]) + vi.mocked(prisma.stream.findMany) + .mockResolvedValueOnce([{ + id: '5', + createdAt: new Date(), + updatedAt: new Date(), + streamId: 13, + sender: 'GSENDER5', + recipient: 'GRECIPIENT5', + tokenAddress: 'TOKEN5', + ratePerSecond: '1', + depositedAmount: '100', + withdrawnAmount: '1', + startTime: 1000, + lastUpdateTime: 2000, + isActive: true + }]) .mockResolvedValueOnce([]); const address = 'GCACHE000000000000000000000000000000000000000000000000000000'; diff --git a/contracts/stream_contract/src/test.rs b/contracts/stream_contract/src/test.rs index d511f80..3a966bc 100644 --- a/contracts/stream_contract/src/test.rs +++ b/contracts/stream_contract/src/test.rs @@ -1318,7 +1318,7 @@ fn test_resume_adjusts_end_time() { // Paused for 300 seconds. env.ledger().with_mut(|l| l.timestamp += 300); - let new_end = client.resume_stream(&sender, &id); + let _new_end = client.resume_stream(&sender, &id); // After resume, stream should be active again. let s = client.get_stream(&id).unwrap(); diff --git a/frontend/package.json b/frontend/package.json index 303c0ad..614d277 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,7 +32,7 @@ "@types/react-dom": "^19", "@vitejs/plugin-react": "^4.7.0", "eslint": "^9", - "eslint-config-next": "16.1.6", + "eslint-config-next": "^16.1.6", "happy-dom": "^20.9.0", "jsdom": "^27.0.1", "tailwindcss": "^4", diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index bd6f479..0f66854 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } 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 toast from "react-hot-toast"; @@ -286,9 +287,9 @@ export default function SettingsPage() {

Not connected - + Connect Wallet - +
)} diff --git a/frontend/src/app/streams/create/page.tsx b/frontend/src/app/streams/create/page.tsx index e0fb0f6..7c418ef 100644 --- a/frontend/src/app/streams/create/page.tsx +++ b/frontend/src/app/streams/create/page.tsx @@ -1,3 +1,6 @@ +"use client"; + +import React, { useState } from "react"; import { createStream, toBaseUnits, @@ -7,6 +10,9 @@ import { } from "@/lib/soroban"; 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"; export default function CreateStreamPage() { const { status, session } = useWallet(); diff --git a/frontend/src/components/Livecounter.tsx b/frontend/src/components/Livecounter.tsx index 97437bf..c92709d 100644 --- a/frontend/src/components/Livecounter.tsx +++ b/frontend/src/components/Livecounter.tsx @@ -17,10 +17,14 @@ export default function LiveCounter({ }: LiveCounterProps) { const [amount, setAmount] = useState(initial); + useEffect(() => { + // Reset amount when initial value changes + setAmount(initial); + }, [initial]); + useEffect(() => { // Don't increment if paused if (isPaused) { - setAmount(initial); return; } diff --git a/frontend/src/components/NotificationDropdown.tsx b/frontend/src/components/NotificationDropdown.tsx index 741ef15..be0800d 100644 --- a/frontend/src/components/NotificationDropdown.tsx +++ b/frontend/src/components/NotificationDropdown.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { useState, useEffect } from 'react'; -import { BackendStreamEvent } from '@/lib/api-types'; -import { fetchUserEvents } from '@/lib/dashboard'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { useStreamEvents } from '@/hooks/useStreamEvents'; +import { formatAmount } from '@/lib/amount'; import { Button } from './ui/Button'; import { useStreamEvents } from '@/hooks/useStreamEvents'; @@ -9,8 +9,48 @@ interface NotificationDropdownProps { publicKey: string; } +interface NotificationItem { + id: string; + streamId: number; + type: 'created' | 'topped_up' | 'withdrawn' | 'cancelled' | 'completed' | 'paused' | 'resumed'; + message: string; + timestamp: number; + read: boolean; +} + export const NotificationDropdown: React.FC = ({ publicKey }) => { const [isOpen, setIsOpen] = useState(false); + const [notifications, setNotifications] = useState([]); + + // Subscribe to live stream events for the user + const { events, connected } = useStreamEvents({ + userPublicKeys: [publicKey], + autoReconnect: true + }); + + const formatEventMessage = useCallback((event: { type: string; data?: unknown }): string => { + const data = event.data as { streamId?: number; amount?: string; tokenSymbol?: string }; + const streamId = data?.streamId || 0; + const amount = data?.amount ? formatAmount(BigInt(data.amount), 7) : '0'; + const tokenSymbol = data?.tokenSymbol || 'USDC'; + + switch (event.type) { + case 'created': + return `New stream #${streamId} created`; + case 'topped_up': + return `Stream #${streamId} was topped up by ${amount} ${tokenSymbol}`; + case 'withdrawn': + return `You received ${amount} ${tokenSymbol} from stream #${streamId}`; + case 'cancelled': + return `Stream #${streamId} was cancelled — refund incoming`; + case 'completed': + return `Stream #${streamId} completed`; + case 'paused': + return `Stream #${streamId} was paused`; + case 'resumed': + return `Stream #${streamId} was resumed`; + default: + return `Activity on stream #${streamId}`; const [events, setEvents] = useState([]); const [isLoading, setIsLoading] = useState(false); const [unreadCount, setUnreadCount] = useState(0); @@ -26,8 +66,31 @@ export const NotificationDropdown: React.FC = ({ publ loadEvents(); setUnreadCount(0); // Clear unread count when dropdown opens } - }, [isOpen, publicKey]); + }, []); + + // Process live events into notifications + useEffect(() => { + const newNotifications = events.map(event => ({ + id: `${event.type}-${event.timestamp}`, + streamId: (event.data as { streamId?: number })?.streamId || 0, + type: event.type, + message: formatEventMessage(event), + timestamp: event.timestamp, + read: false + })); + if (newNotifications.length > 0) { + // Use setTimeout to defer state update and avoid linting violation + setTimeout(() => { + setNotifications(prev => { + // Combine with existing notifications, remove duplicates, keep latest 20 + const combined = [...newNotifications, ...prev]; + const unique = combined.filter((notif, index, self) => + index === self.findIndex(n => n.id === notif.id) + ); + return unique.slice(0, 20); + }); + }, 0); // Handle incoming SSE events useEffect(() => { if (streamEvents.length > 0 && !isOpen) { @@ -75,8 +138,18 @@ export const NotificationDropdown: React.FC = ({ publ } finally { setIsLoading(false); } - }; + }, [events, formatEventMessage]); + + // Calculate unread count from notifications + const unreadCount = useMemo(() => { + return notifications.filter(n => !n.read).length; + }, [notifications]); + // Mark all as read when dropdown opens + const handleDropdownOpen = useCallback(() => { + setIsOpen(true); + if (unreadCount > 0) { + setNotifications(prev => prev.map(n => ({ ...n, read: true }))); const formatEventMessage = (event: BackendStreamEvent) => { const amount = event.amount ? parseFloat(event.amount) / 1e7 : 0; switch (event.eventType) { @@ -87,18 +160,27 @@ export const NotificationDropdown: React.FC = ({ publ case 'COMPLETED': return `Completed #${event.streamId}`; default: return `Event on #${event.streamId}`; } - }; + }, [unreadCount]); return (
+
+ {!connected && ( + Reconnecting... + )} + +
- {isLoading ? ( -
-
-
- ) : events.length > 0 ? ( + {notifications.length > 0 ? (
- {events.map((event) => ( -
-

{formatEventMessage(event)}

+ {notifications.map((notification) => ( +
+

{notification.message}

- {new Date(event.timestamp * 1000).toLocaleString()} + {new Date(notification.timestamp).toLocaleString()}

))}
) : (
- No new notifications + {connected ? 'No new notifications' : 'Connecting to live updates...'}
)}
-
diff --git a/frontend/src/components/dashboard/ActivityHistory.tsx b/frontend/src/components/dashboard/ActivityHistory.tsx index 7dd48c1..0ee6503 100644 --- a/frontend/src/components/dashboard/ActivityHistory.tsx +++ b/frontend/src/components/dashboard/ActivityHistory.tsx @@ -84,33 +84,12 @@ export const ActivityHistory: React.FC = ({ events, isLoad {event.eventType}
- {event.amount && ( -
- Amount: {fromStroops(BigInt(event.amount), 7)} -
- )} - {event.txHash && ( -
- Tx: - - {event.txHash} - - - - -
- )} - {(event.txHash || event.transactionStatus) && ( + {event.transactionHash && (
)} diff --git a/frontend/src/components/dashboard/SSEStatusIndicator.tsx b/frontend/src/components/dashboard/SSEStatusIndicator.tsx index cfba090..a77d8a8 100644 --- a/frontend/src/components/dashboard/SSEStatusIndicator.tsx +++ b/frontend/src/components/dashboard/SSEStatusIndicator.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useMemo } from "react"; import { X, AlertCircle } from "lucide-react"; interface SSEStatusIndicatorProps { @@ -14,16 +14,9 @@ export function SSEStatusIndicator({ reconnecting, error, }: SSEStatusIndicatorProps) { - const [showDisconnectBanner, setShowDisconnectBanner] = useState(false); - // Show disconnect banner when error occurs - useEffect(() => { - if (error || (reconnecting && !connected)) { - setShowDisconnectBanner(true); - } else { - // Auto-dismiss banner when reconnected - setShowDisconnectBanner(false); - } + const showDisconnectBanner = useMemo(() => { + return !!error || (reconnecting && !connected); }, [connected, reconnecting, error]); const isLive = connected && !reconnecting && !error; @@ -76,13 +69,6 @@ export function SSEStatusIndicator({ : "Real-time updates paused. Data may be stale."}

- )} diff --git a/frontend/src/components/dashboard/StreamDetailsModal.tsx b/frontend/src/components/dashboard/StreamDetailsModal.tsx index 0686a89..66acdce 100644 --- a/frontend/src/components/dashboard/StreamDetailsModal.tsx +++ b/frontend/src/components/dashboard/StreamDetailsModal.tsx @@ -1,9 +1,8 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import { Button } from "@/components/ui/Button"; import type { Stream } from "@/lib/dashboard"; -import { shortenPublicKey } from "@/lib/wallet"; interface StreamDetailsModalProps { stream: Stream; diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx index 34f51f7..221a13f 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -459,10 +459,6 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { setStreamFormMessage(null); }; - const handleTopUp = (stream: Stream) => { - setModal({ type: "topup", stream }); - }; - const handleApplyTemplate = (templateId: string) => { const template = templates.find((item) => item.id === templateId); if (!template) return; @@ -792,7 +788,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {

Start your first stream

- You haven't created or received any payment streams yet. + You haven't created or received any payment streams yet. Connect with others and start streaming tokens in real-time.