From 1fdcbbd45f54ddb89894531f36817943eab3978b Mon Sep 17 00:00:00 2001
From: Anonfedora
Date: Tue, 28 Apr 2026 13:54:41 +0100
Subject: [PATCH 1/2] feature:Status filter, shared token, live notification,
contract deployment
---
backend/src/controllers/stream.controller.ts | 99 +++++++++++++++++---
backend/src/lib/redis.ts | 15 ++-
backend/src/services/sse.service.ts | 1 +
backend/src/workers/soroban-event-worker.ts | 1 -
backend/tests/sse.service.test.ts | 2 +-
backend/tests/stream.test.ts | 76 ++++++++++++---
6 files changed, 158 insertions(+), 36 deletions(-)
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 dc5c877..53b3611 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';
From 3ddbe39980a75ae491eef6490feeaf4586729b68 Mon Sep 17 00:00:00 2001
From: Anonfedora
Date: Tue, 28 Apr 2026 13:59:21 +0100
Subject: [PATCH 2/2] feature: Status filter, shared token, live notification,
contract deployment
---
README.md | 90 +++++++
contracts/stream_contract/src/test.rs | 2 +-
frontend/package.json | 2 +-
frontend/src/app/settings/page.tsx | 5 +-
frontend/src/app/streams/create/page.tsx | 6 +
frontend/src/components/Livecounter.tsx | 6 +-
.../src/components/NotificationDropdown.tsx | 170 ++++++++----
.../components/dashboard/ActivityHistory.tsx | 8 +-
.../dashboard/SSEStatusIndicator.tsx | 20 +-
.../dashboard/StreamDetailsModal.tsx | 3 +-
.../components/dashboard/dashboard-view.tsx | 11 +-
frontend/src/lib/amount.ts | 169 ++++++++++++
package-lock.json | 29 +-
scripts/deploy.ts | 247 ++++++++++++++++++
14 files changed, 656 insertions(+), 112 deletions(-)
create mode 100644 frontend/src/lib/amount.ts
create mode 100644 scripts/deploy.ts
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/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 094fcd2..88a2e52 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -25,7 +25,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
- "eslint-config-next": "16.1.6",
+ "eslint-config-next": "^16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
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() {
)}
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 9e01fcc..f2942c3 100644
--- a/frontend/src/components/NotificationDropdown.tsx
+++ b/frontend/src/components/NotificationDropdown.tsx
@@ -1,58 +1,116 @@
"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';
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 [events, setEvents] = useState([]);
- const [isLoading, setIsLoading] = useState(false);
+ const [notifications, setNotifications] = useState([]);
- useEffect(() => {
- if (isOpen && publicKey) {
- loadEvents();
+ // 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}`;
}
- }, [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
+ }));
- const loadEvents = async () => {
- setIsLoading(true);
- try {
- const data = await fetchUserEvents(publicKey);
- setEvents(data.slice(0, 5)); // Show only last 5
- } catch (error) {
- console.error(error);
- } finally {
- setIsLoading(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);
}
- };
+ }, [events, formatEventMessage]);
+
+ // Calculate unread count from notifications
+ const unreadCount = useMemo(() => {
+ return notifications.filter(n => !n.read).length;
+ }, [notifications]);
- const formatEventMessage = (event: BackendStreamEvent) => {
- const amount = event.amount ? parseFloat(event.amount) / 1e7 : 0;
- switch (event.eventType) {
- case 'CREATED': return `New stream #${event.streamId}`;
- case 'TOPPED_UP': return `Topped up #${event.streamId}`;
- case 'WITHDRAWN': return `Withdrew ${amount} from #${event.streamId}`;
- case 'CANCELLED': return `Cancelled #${event.streamId}`;
- default: return `Event on #${event.streamId}`;
+ // Mark all as read when dropdown opens
+ const handleDropdownOpen = useCallback(() => {
+ setIsOpen(true);
+ if (unreadCount > 0) {
+ setNotifications(prev => prev.map(n => ({ ...n, read: true })));
}
- };
+ }, [unreadCount]);
return (
setIsOpen(!isOpen)}
+ onClick={handleDropdownOpen}
className="relative p-2 text-slate-400 hover:text-accent transition-colors"
+ disabled={!connected}
>
- {events.length > 0 && (
-
+ {unreadCount > 0 && (
+
+
+ {unreadCount > 9 ? '9+' : unreadCount}
+
+
+ )}
+ {!connected && (
+
)}
@@ -60,39 +118,51 @@ export const NotificationDropdown: React.FC
= ({ publ
Notifications
-
setIsOpen(false)}
- className="text-slate-400 hover:text-white"
- >
-
-
-
-
+
+ {!connected && (
+
Reconnecting...
+ )}
+
setIsOpen(false)}
+ className="text-slate-400 hover:text-white"
+ >
+
+
+
+
+
- {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...'}
)}
-
+ {
+ // Navigate to activity page
+ window.location.href = '/activity';
+ }}
+ >
View All Activity
diff --git a/frontend/src/components/dashboard/ActivityHistory.tsx b/frontend/src/components/dashboard/ActivityHistory.tsx
index 0dc05a6..27e489d 100644
--- a/frontend/src/components/dashboard/ActivityHistory.tsx
+++ b/frontend/src/components/dashboard/ActivityHistory.tsx
@@ -65,12 +65,12 @@ export const ActivityHistory: React.FC
= ({ events, isLoad
{event.eventType}
- {(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."}
- setShowDisconnectBanner(false)}
- className="flex-shrink-0 p-1 hover:bg-red-600/50 rounded transition"
- aria-label="Close banner"
- >
-
-
)}
>
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 e98e8a7..221a13f 100644
--- a/frontend/src/components/dashboard/dashboard-view.tsx
+++ b/frontend/src/components/dashboard/dashboard-view.tsx
@@ -459,15 +459,6 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
setStreamFormMessage(null);
};
- const handleTopUp = (streamId: string) => {
- const amount = prompt(`Enter amount to add to stream ${streamId}:`);
- if (amount && !Number.isNaN(parseFloat(amount)) && parseFloat(amount) > 0) {
- console.log(`Adding ${amount} funds to stream ${streamId}`);
- // TODO: Integrate with Soroban contract's top_up_stream function
- alert(`Successfully added ${amount} to stream ${streamId}`);
- }
- };
-
const handleApplyTemplate = (templateId: string) => {
const template = templates.find((item) => item.id === templateId);
if (!template) return;
@@ -797,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.
setShowWizard(true)} glow size="lg">
diff --git a/frontend/src/lib/amount.ts b/frontend/src/lib/amount.ts
new file mode 100644
index 0000000..56fddca
--- /dev/null
+++ b/frontend/src/lib/amount.ts
@@ -0,0 +1,169 @@
+/**
+ * Shared utilities for formatting and parsing token amounts
+ * Handles conversion between raw on-chain amounts (i128) and display values
+ */
+
+/**
+ * Format raw amount (bigint) to display string with proper decimal places
+ * @param raw - Raw amount as bigint
+ * @param decimals - Number of decimal places for the token
+ * @returns Formatted string
+ */
+export function formatAmount(raw: bigint, decimals: number): string {
+ if (raw === 0n) return '0';
+
+ const divisor = 10n ** BigInt(decimals);
+ const whole = raw / divisor;
+ const fractional = raw % divisor;
+
+ if (fractional === 0n) {
+ return whole.toString();
+ }
+
+ // Pad fractional part with leading zeros
+ const fractionalStr = fractional.toString().padStart(decimals, '0');
+
+ // Remove trailing zeros
+ const trimmedFractional = fractionalStr.replace(/0+$/, '');
+
+ return `${whole}.${trimmedFractional}`;
+}
+
+/**
+ * Parse display string back to raw amount (bigint)
+ * @param display - Display string (e.g., "1.234")
+ * @param decimals - Number of decimal places for the token
+ * @returns Raw amount as bigint
+ */
+export function parseAmount(display: string, decimals: number): bigint {
+ if (!display || display.trim() === '') return 0n;
+
+ const cleanDisplay = display.trim();
+ const divisor = 10n ** BigInt(decimals);
+
+ if (cleanDisplay.includes('.')) {
+ const [wholePart, fractionalPart] = cleanDisplay.split('.');
+ const whole = BigInt(wholePart || '0');
+
+ // Handle fractional part - pad or truncate to correct length
+ let fractional = fractionalPart || '';
+ if (fractional.length > decimals) {
+ // Truncate if too long
+ fractional = fractional.slice(0, decimals);
+ } else {
+ // Pad with zeros if too short
+ fractional = fractional.padEnd(decimals, '0');
+ }
+
+ const fractionalBig = BigInt(fractional || '0');
+ return whole * divisor + fractionalBig;
+ } else {
+ return BigInt(cleanDisplay) * divisor;
+ }
+}
+
+/**
+ * Format rate per second to human-readable string
+ * @param ratePerSec - Rate per second as bigint
+ * @param decimals - Number of decimal places for the token
+ * @param symbol - Token symbol (optional)
+ * @returns Formatted rate string
+ */
+export function formatRate(ratePerSec: bigint, decimals: number, symbol = ''): string {
+ if (ratePerSec === 0n) return '0';
+
+ const ratePerSecond = formatAmount(ratePerSec, decimals);
+ const ratePerDay = formatAmount(ratePerSec * 86400n, decimals); // 86400 seconds in a day
+
+ const symbolStr = symbol ? ` ${symbol}` : '';
+ return `${ratePerSecond}${symbolStr}/sec (${ratePerDay}${symbolStr}/day)`;
+}
+
+/**
+ * Check if input string has valid precision for the given decimals
+ * @param input - Input string to validate
+ * @param decimals - Maximum allowed decimal places
+ * @returns True if valid precision
+ */
+export function hasValidPrecision(input: string, decimals: number): boolean {
+ if (!input || input.trim() === '') return true; // Empty is valid (will be parsed as 0)
+
+ const cleanInput = input.trim();
+
+ // Check if it's a valid number format
+ if (!/^\d*\.?\d*$/.test(cleanInput)) return false;
+
+ if (cleanInput.includes('.')) {
+ const fractionalPart = cleanInput.split('.')[1];
+ return fractionalPart.length <= decimals;
+ }
+
+ return true;
+}
+
+/**
+ * Convert value to stroops (smallest unit, 7 decimal places for XLM)
+ * @param value - String value in XLM
+ * @returns Value in stroops as bigint
+ */
+export function toStroops(value: string): bigint {
+ return parseAmount(value, 7); // XLM uses 7 decimal places
+}
+
+/**
+ * Convert stroops back to XLM string
+ * @param stroops - Value in stroops as bigint
+ * @returns XLM string
+ */
+export function fromStroops(stroops: bigint): string {
+ return formatAmount(stroops, 7);
+}
+
+/**
+ * Truncate amount to specified decimal places without rounding
+ * @param amount - Amount as bigint
+ * @param decimals - Token decimals
+ * @param maxDisplayDecimals - Maximum decimal places to display
+ * @returns Truncated string
+ */
+export function truncateAmount(amount: bigint, decimals: number, maxDisplayDecimals: number): string {
+ if (amount === 0n) return '0';
+
+ const divisor = 10n ** BigInt(decimals);
+ const whole = amount / divisor;
+ const fractional = amount % divisor;
+
+ if (fractional === 0n) {
+ return whole.toString();
+ }
+
+ // Convert fractional to string and truncate
+ const fractionalStr = fractional.toString().padStart(decimals, '0');
+ const truncatedFractional = fractionalStr.slice(0, maxDisplayDecimals);
+
+ // Remove trailing zeros from truncated part
+ const trimmedFractional = truncatedFractional.replace(/0+$/, '');
+
+ if (trimmedFractional === '') {
+ return whole.toString();
+ }
+
+ return `${whole}.${trimmedFractional}`;
+}
+
+/**
+ * Format amount with compact notation (K, M, B) for large numbers
+ * @param amount - Amount as bigint
+ * @param decimals - Token decimals
+ * @returns Compact formatted string
+ */
+export function formatCompactAmount(amount: bigint, decimals: number): string {
+ const displayAmount = formatAmount(amount, decimals);
+ const num = parseFloat(displayAmount);
+
+ if (num === 0) return '0';
+ if (num < 1000) return displayAmount;
+ if (num < 1000000) return `${(num / 1000).toFixed(1)}K`;
+ if (num < 1000000000) return `${(num / 1000000).toFixed(1)}M`;
+ return `${(num / 1000000000).toFixed(1)}B`;
+}
diff --git a/package-lock.json b/package-lock.json
index 9f48adc..73cf52b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -82,7 +82,6 @@
"version": "25.3.0",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -286,7 +285,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -426,7 +424,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
- "eslint-config-next": "16.1.6",
+ "eslint-config-next": "^16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -503,7 +501,6 @@
"version": "7.29.0",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -755,8 +752,7 @@
"node_modules/@electric-sql/pglite": {
"version": "0.3.15",
"dev": true,
- "license": "Apache-2.0",
- "peer": true
+ "license": "Apache-2.0"
},
"node_modules/@electric-sql/pglite-socket": {
"version": "0.0.20",
@@ -2135,7 +2131,6 @@
"version": "20.19.33",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2169,7 +2164,6 @@
"version": "19.2.14",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2276,7 +2270,6 @@
"version": "8.56.1",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -2528,7 +2521,6 @@
"version": "8.16.0",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3020,7 +3012,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3516,8 +3507,7 @@
},
"node_modules/csstype": {
"version": "3.2.3",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@@ -4398,7 +4388,6 @@
"version": "9.39.3",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4455,6 +4444,8 @@
},
"node_modules/eslint-config-next": {
"version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
+ "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4568,7 +4559,6 @@
"version": "2.32.0",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -4821,7 +4811,6 @@
"node_modules/express": {
"version": "5.2.1",
"license": "MIT",
- "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -5501,7 +5490,6 @@
"version": "4.11.4",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -7205,7 +7193,6 @@
"node_modules/pg": {
"version": "8.18.0",
"license": "MIT",
- "peer": true,
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0",
@@ -7409,7 +7396,6 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@prisma/config": "7.4.1",
"@prisma/dev": "0.20.0",
@@ -7576,7 +7562,6 @@
"node_modules/react": {
"version": "19.2.4",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7584,7 +7569,6 @@
"node_modules/react-dom": {
"version": "19.2.4",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -8763,7 +8747,6 @@
"version": "4.0.3",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -9037,7 +9020,6 @@
"version": "5.9.3",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9642,7 +9624,6 @@
"node_modules/zod": {
"version": "4.3.6",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/scripts/deploy.ts b/scripts/deploy.ts
new file mode 100644
index 0000000..ca71e57
--- /dev/null
+++ b/scripts/deploy.ts
@@ -0,0 +1,247 @@
+#!/usr/bin/env tsx
+
+/**
+ * FlowFi Contract Deployment Script
+ *
+ * This script automates the deployment and initialization of FlowFi smart contracts
+ * to both testnet and mainnet Stellar networks.
+ *
+ * Usage:
+ * npx tsx scripts/deploy.ts --network testnet
+ * npx tsx scripts/deploy.ts --network mainnet
+ *
+ * Environment Variables Required:
+ * - STELLAR_SECRET_KEY: Secret key for deployment account
+ * - ADMIN_ADDRESS: Admin address for contract initialization
+ * - TREASURY_ADDRESS: Treasury address for fee collection
+ * - FEE_RATE_BPS: Fee rate in basis points (e.g., 25 for 0.25%)
+ */
+
+import { execSync } from 'child_process';
+import { writeFileSync, readFileSync, existsSync } from 'fs';
+import { join } from 'path';
+
+interface DeploymentInfo {
+ network: string;
+ contractId: string;
+ deployedAt: string;
+ adminAddress: string;
+ treasuryAddress: string;
+ feeRateBps: number;
+ transactionHash: string;
+}
+
+interface Config {
+ network: 'testnet' | 'mainnet';
+ adminAddress: string;
+ treasuryAddress: string;
+ feeRateBps: number;
+ secretKey: string;
+}
+
+// Parse command line arguments
+function parseArgs(): Config {
+ const args = process.argv.slice(2);
+ const networkArg = args.find(arg => arg.startsWith('--network='))?.split('=')[1];
+
+ if (!networkArg || !['testnet', 'mainnet'].includes(networkArg)) {
+ console.error('❌ Invalid or missing network. Use --network=testnet or --network=mainnet');
+ process.exit(1);
+ }
+
+ // Validate required environment variables
+ const requiredEnvVars = ['STELLAR_SECRET_KEY', 'ADMIN_ADDRESS', 'TREASURY_ADDRESS', 'FEE_RATE_BPS'];
+ const missingVars = requiredEnvVars.filter(varName => !process.env[varName]);
+
+ if (missingVars.length > 0) {
+ console.error('❌ Missing required environment variables:');
+ missingVars.forEach(varName => console.error(` - ${varName}`));
+ console.error('\nPlease set these environment variables before running the script.');
+ process.exit(1);
+ }
+
+ const feeRateBps = parseInt(process.env.FEE_RATE_BPS!);
+ if (isNaN(feeRateBps) || feeRateBps < 0 || feeRateBps > 10000) {
+ console.error('❌ FEE_RATE_BPS must be a number between 0 and 10000 (0% to 100%)');
+ process.exit(1);
+ }
+
+ return {
+ network: networkArg as 'testnet' | 'mainnet',
+ adminAddress: process.env.ADMIN_ADDRESS!,
+ treasuryAddress: process.env.TREASURY_ADDRESS!,
+ feeRateBps,
+ secretKey: process.env.STELLAR_SECRET_KEY!
+ };
+}
+
+// Execute command and handle errors
+function runCommand(command: string, description: string): void {
+ console.log(`🔧 ${description}...`);
+ try {
+ execSync(command, { stdio: 'inherit', cwd: join(process.cwd(), 'contracts') });
+ console.log(`✅ ${description} completed`);
+ } catch (error) {
+ console.error(`❌ ${description} failed:`, error);
+ process.exit(1);
+ }
+}
+
+// Get network-specific configuration
+function getNetworkConfig(network: string) {
+ const configs = {
+ testnet: {
+ rpcUrl: 'https://soroban-testnet.stellar.org',
+ horizonUrl: 'https://horizon-testnet.stellar.org',
+ friendbotUrl: 'https://friendbot.stellar.org',
+ networkPassphrase: 'Test SDF Network ; September 2015'
+ },
+ mainnet: {
+ rpcUrl: 'https://soroban-rpc.stellar.org',
+ horizonUrl: 'https://horizon.stellar.org',
+ friendbotUrl: '',
+ networkPassphrase: 'Public Global Stellar Network ; September 2015'
+ }
+ };
+
+ return configs[network as keyof typeof configs];
+}
+
+// Save deployment information
+function saveDeploymentInfo(info: DeploymentInfo): void {
+ const filePath = join(process.cwd(), 'deployment-info.json');
+ const existingData = existsSync(filePath) ? JSON.parse(readFileSync(filePath, 'utf8')) : {};
+
+ // Update or add deployment info for this network
+ existingData[info.network] = info;
+ existingData.lastUpdated = new Date().toISOString();
+
+ writeFileSync(filePath, JSON.stringify(existingData, null, 2));
+ console.log(`💾 Deployment info saved to ${filePath}`);
+}
+
+// Main deployment function
+async function deploy(): Promise {
+ console.log('🚀 Starting FlowFi Contract Deployment...\n');
+
+ const config = parseArgs();
+ const networkConfig = getNetworkConfig(config.network);
+
+ console.log(`📋 Configuration:`);
+ console.log(` Network: ${config.network}`);
+ console.log(` Admin: ${config.adminAddress}`);
+ console.log(` Treasury: ${config.treasuryAddress}`);
+ console.log(` Fee Rate: ${config.feeRateBps} bps (${config.feeRateBps / 100}%)`);
+ console.log('');
+
+ // Step 1: Build WASM
+ console.log('📦 Step 1: Building WASM...');
+ runCommand('cargo build --target wasm32-unknown-unknown --release', 'Building WASM');
+
+ // Step 2: Optimize WASM
+ console.log('\n⚡ Step 2: Optimizing WASM...');
+ const wasmPath = join('contracts', 'target', 'wasm32-unknown-unknown', 'release', 'stream_contract.wasm');
+ runCommand(`stellar contract optimize --wasm ${wasmPath}`, 'Optimizing WASM');
+
+ // Step 3: Deploy contract
+ console.log('\n🚀 Step 3: Deploying contract...');
+ const optimizedWasmPath = wasmPath.replace('.wasm', '.optimized.wasm');
+
+ try {
+ const deployCommand = [
+ 'stellar contract deploy',
+ `--wasm ${optimizedWasmPath}`,
+ `--source ${config.secretKey}`,
+ `--network ${networkConfig.rpcUrl}`,
+ '--network-passphrase "' + networkConfig.networkPassphrase + '"'
+ ].join(' ');
+
+ console.log(`🔧 Deploying contract...`);
+ const deployOutput = execSync(deployCommand, {
+ encoding: 'utf8',
+ cwd: join(process.cwd(), 'contracts')
+ });
+
+ // Extract contract ID from output
+ const contractIdMatch = deployOutput.match(/Contract ID: ([A-Z0-9]+)/);
+ if (!contractIdMatch) {
+ throw new Error('Could not extract contract ID from deployment output');
+ }
+
+ const contractId = contractIdMatch[1];
+ console.log(`✅ Contract deployed with ID: ${contractId}`);
+
+ // Step 4: Initialize contract
+ console.log('\n⚙️ Step 4: Initializing contract...');
+ const initCommand = [
+ 'stellar contract invoke',
+ `--id ${contractId}`,
+ `--source ${config.secretKey}`,
+ `--network ${networkConfig.rpcUrl}`,
+ '--network-passphrase "' + networkConfig.networkPassphrase + '"',
+ 'initialize',
+ `--admin ${config.adminAddress}`,
+ `--treasury ${config.treasuryAddress}`,
+ `--fee_rate_bps ${config.feeRateBps}`
+ ].join(' ');
+
+ console.log(`🔧 Initializing contract...`);
+ const initOutput = execSync(initCommand, {
+ encoding: 'utf8',
+ cwd: join(process.cwd(), 'contracts')
+ });
+
+ // Extract transaction hash from output
+ const txHashMatch = initOutput.match(/Transaction hash: ([A-Z0-9]+)/);
+ const txHash = txHashMatch ? txHashMatch[1] : 'unknown';
+
+ console.log(`✅ Contract initialized successfully`);
+
+ // Step 5: Save deployment info
+ const deploymentInfo: DeploymentInfo = {
+ network: config.network,
+ contractId,
+ deployedAt: new Date().toISOString(),
+ adminAddress: config.adminAddress,
+ treasuryAddress: config.treasuryAddress,
+ feeRateBps: config.feeRateBps,
+ transactionHash: txHash
+ };
+
+ saveDeploymentInfo(deploymentInfo);
+
+ // Step 6: Display summary
+ console.log('\n🎉 Deployment Summary:');
+ console.log(` Network: ${config.network}`);
+ console.log(` Contract ID: ${contractId}`);
+ console.log(` Transaction Hash: ${txHash}`);
+ console.log(` Admin: ${config.adminAddress}`);
+ console.log(` Treasury: ${config.treasuryAddress}`);
+ console.log(` Fee Rate: ${config.feeRateBps} bps`);
+ console.log(` Deployed At: ${deploymentInfo.deployedAt}`);
+ console.log('\n✅ Deployment completed successfully!');
+
+ } catch (error) {
+ console.error('❌ Deployment failed:', error);
+ process.exit(1);
+ }
+}
+
+// Handle errors gracefully
+process.on('uncaughtException', (error) => {
+ console.error('❌ Uncaught exception:', error);
+ process.exit(1);
+});
+
+process.on('unhandledRejection', (reason, promise) => {
+ console.error('❌ Unhandled rejection at:', promise, 'reason:', reason);
+ process.exit(1);
+});
+
+// Run deployment
+if (require.main === module) {
+ deploy().catch(error => {
+ console.error('❌ Deployment failed:', error);
+ process.exit(1);
+ });
+}