Skip to content

Commit 5601e96

Browse files
authored
Merge branch 'main' into feature/stream-activity-history-340
2 parents 563b4e9 + c2113a2 commit 5601e96

30 files changed

Lines changed: 2542 additions & 823 deletions

README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,96 @@ cd contracts
101101
cargo build --target wasm32-unknown-unknown --release
102102
```
103103

104+
## Deployment
105+
106+
### Contract Deployment
107+
108+
The FlowFi smart contracts can be deployed to both testnet and mainnet using the automated deployment script.
109+
110+
#### Prerequisites
111+
112+
- Stellar CLI installed and configured
113+
- Sufficient XLM in the deployment account for network fees
114+
- Required environment variables set
115+
116+
#### Environment Variables
117+
118+
Before deploying, set the following environment variables:
119+
120+
```bash
121+
export STELLAR_SECRET_KEY="your_secret_key_here"
122+
export ADMIN_ADDRESS="your_admin_address_here"
123+
export TREASURY_ADDRESS="your_treasury_address_here"
124+
export FEE_RATE_BPS="25" # 0.25% fee rate
125+
```
126+
127+
#### Deploy to Testnet
128+
129+
```bash
130+
npx tsx scripts/deploy.ts --network testnet
131+
```
132+
133+
#### Deploy to Mainnet
134+
135+
```bash
136+
npx tsx scripts/deploy.ts --network mainnet
137+
```
138+
139+
#### Deployment Process
140+
141+
The deployment script automates the following steps:
142+
143+
1. **Build WASM**: Compiles the Rust contract to WebAssembly
144+
2. **Optimize WASM**: Optimizes the WASM for deployment size
145+
3. **Deploy Contract**: Deploys the contract to the specified network
146+
4. **Initialize Contract**: Sets up admin, treasury, and fee rate parameters
147+
5. **Save Deployment Info**: Stores contract details in `deployment-info.json`
148+
149+
#### Deployment Information
150+
151+
After successful deployment, contract details are saved to `deployment-info.json`:
152+
153+
```json
154+
{
155+
"testnet": {
156+
"network": "testnet",
157+
"contractId": "CD...ID",
158+
"deployedAt": "2024-01-01T00:00:00.000Z",
159+
"adminAddress": "G...ADMIN",
160+
"treasuryAddress": "G...TREASURY",
161+
"feeRateBps": 25,
162+
"transactionHash": "TX...HASH"
163+
},
164+
"mainnet": {
165+
"network": "mainnet",
166+
"contractId": "CD...ID",
167+
"deployedAt": "2024-01-01T00:00:00.000Z",
168+
"adminAddress": "G...ADMIN",
169+
"treasuryAddress": "G...TREASURY",
170+
"feeRateBps": 25,
171+
"transactionHash": "TX...HASH"
172+
},
173+
"lastUpdated": "2024-01-01T00:00:00.000Z"
174+
}
175+
```
176+
177+
#### Manual Deployment
178+
179+
If you prefer to deploy manually, you can use the Stellar CLI directly:
180+
181+
```bash
182+
# Build and optimize
183+
cd contracts
184+
cargo build --target wasm32-unknown-unknown --release
185+
stellar contract optimize --wasm target/wasm32-unknown-unknown/release/stream_contract.wasm
186+
187+
# Deploy
188+
stellar contract deploy --wasm target/wasm32-unknown-unknown/release/stream_contract.optimized.wasm --source YOUR_SECRET_KEY --network https://soroban-testnet.stellar.org
189+
190+
# Initialize
191+
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
192+
```
193+
104194
## API Documentation
105195

106196
The FlowFi backend API uses URL-based versioning. All endpoints are prefixed with a version (e.g., `/v1/streams`).

backend/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"@prisma/adapter-pg": "^7.4.1",
2626
"@stellar/stellar-sdk": "^14.5.0",
2727
"cors": "^2.8.6",
28-
"dotenv": "^17.3.1",
28+
"dotenv": "^17.4.2",
2929
"express": "^5.2.1",
3030
"express-rate-limit": "^8.2.1",
3131
"ioredis": "^5.3.2",
@@ -53,4 +53,4 @@
5353
"typescript": "^5.9.3",
5454
"vitest": "^2.1.8"
5555
}
56-
}
56+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- AlterTable
2+
ALTER TABLE "Stream" ADD COLUMN "isPaused" BOOLEAN NOT NULL DEFAULT false;
3+
ALTER TABLE "Stream" ADD COLUMN "pausedAt" INTEGER;
4+
ALTER TABLE "Stream" ADD COLUMN "totalPausedDuration" INTEGER NOT NULL DEFAULT 0;
5+
6+
-- CreateIndex
7+
CREATE INDEX "Stream_isPaused_idx" ON "Stream"("isPaused");

backend/prisma/schema.prisma

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ model Stream {
3636
withdrawnAmount String // Total withdrawn amount (i128)
3737
startTime Int // Unix timestamp when stream started
3838
lastUpdateTime Int // Unix timestamp of last update
39+
endTime Int? // Unix timestamp when stream ends
3940
isActive Boolean @default(true)
41+
isPaused Boolean @default(false)
42+
pausedAt Int? // Unix timestamp when paused
43+
totalPausedDuration Int @default(0) // Accumulated paused duration in seconds
4044
createdAt DateTime @default(now())
4145
updatedAt DateTime @updatedAt
4246
@@ -49,6 +53,7 @@ model Stream {
4953
@@index([recipient])
5054
@@index([streamId])
5155
@@index([isActive])
56+
@@index([isPaused])
5257
}
5358

5459
// IndexerState model - tracks the last processed ledger/cursor for the Soroban event worker
@@ -63,7 +68,7 @@ model IndexerState {
6368
model StreamEvent {
6469
id String @id @default(uuid())
6570
streamId Int // Reference to on-chain stream ID
66-
eventType String // EventType: "CREATED", "TOPPED_UP", "WITHDRAWN", "CANCELLED", "COMPLETED"
71+
eventType String // EventType: "CREATED", "TOPPED_UP", "WITHDRAWN", "CANCELLED", "COMPLETED", "PAUSED", "RESUMED"
6772
amount String? // Amount involved in the event (for top-ups, withdrawals)
6873
transactionHash String // Stellar transaction hash
6974
ledgerSequence Int // Ledger sequence number

backend/src/controllers/stream.controller.ts

Lines changed: 95 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export const createStream = async (req: Request, res: Response) => {
6767
depositedAmount,
6868
withdrawnAmount: "0",
6969
startTime: parseInt(startTime),
70+
endTime: parseInt(startTime) + Number(BigInt(depositedAmount) / BigInt(ratePerSecond)),
7071
lastUpdateTime: parseInt(startTime)
7172
}
7273
});
@@ -79,26 +80,95 @@ export const createStream = async (req: Request, res: Response) => {
7980
};
8081

8182
/**
82-
* List streams by sender or recipient
83+
* List streams by sender, recipient, status, token with sorting and pagination
8384
*/
8485
export const listStreams = async (req: Request, res: Response) => {
8586
try {
86-
const { sender, recipient } = req.query;
87+
const {
88+
sender,
89+
recipient,
90+
status,
91+
token,
92+
sort = 'createdAt',
93+
order = 'desc',
94+
limit = '20',
95+
offset = '0'
96+
} = req.query;
8797

8898
const where: any = {};
8999
if (typeof sender === 'string') where.sender = sender;
90100
if (typeof recipient === 'string') where.recipient = recipient;
101+
if (typeof token === 'string') where.tokenAddress = token;
91102

92-
const streams = await prisma.stream.findMany({
93-
where,
94-
orderBy: { createdAt: 'desc' },
95-
include: {
96-
senderUser: true,
97-
recipientUser: true
103+
// Handle status filtering
104+
if (typeof status === 'string') {
105+
const validStatuses = ['active', 'cancelled', 'completed', 'paused'];
106+
if (!validStatuses.includes(status)) {
107+
return res.status(400).json({
108+
error: 'Invalid status parameter',
109+
message: `status must be one of: ${validStatuses.join(', ')}`
110+
});
98111
}
99-
});
100112

101-
return res.status(200).json(streams);
113+
// Map status to database conditions
114+
switch (status) {
115+
case 'active':
116+
where.isActive = true;
117+
where.isPaused = false;
118+
break;
119+
case 'cancelled':
120+
where.isActive = false;
121+
where.events = { some: { eventType: 'CANCELLED' } };
122+
break;
123+
case 'completed':
124+
where.isActive = false;
125+
where.events = { some: { eventType: 'COMPLETED' } };
126+
break;
127+
case 'paused':
128+
where.isPaused = true;
129+
break;
130+
}
131+
}
132+
133+
// Validate and parse pagination parameters
134+
const parsedLimit = Math.min(
135+
typeof limit === 'string' ? (Number.parseInt(limit, 10) || 20) : 20,
136+
100
137+
);
138+
const parsedOffset = typeof offset === 'string' ? (Number.parseInt(offset, 10) || 0) : 0;
139+
140+
// Validate sort field
141+
const validSortFields = ['createdAt', 'startTime', 'lastUpdateTime', 'depositedAmount', 'endTime'];
142+
const sortField = validSortFields.includes(typeof sort === 'string' ? sort : 'createdAt')
143+
? (sort as 'createdAt' | 'startTime' | 'lastUpdateTime' | 'depositedAmount' | 'endTime')
144+
: 'createdAt';
145+
146+
// Validate order
147+
const sortOrder = order === 'asc' ? 'asc' : 'desc';
148+
149+
const [streams, total] = await Promise.all([
150+
prisma.stream.findMany({
151+
where,
152+
orderBy: { [sortField]: sortOrder },
153+
take: parsedLimit,
154+
skip: parsedOffset,
155+
include: {
156+
senderUser: true,
157+
recipientUser: true
158+
}
159+
}),
160+
prisma.stream.count({ where })
161+
]);
162+
163+
const hasMore = parsedOffset + streams.length < total;
164+
165+
return res.status(200).json({
166+
data: streams,
167+
total,
168+
hasMore,
169+
limit: parsedLimit,
170+
offset: parsedOffset
171+
});
102172
} catch (error) {
103173
logger.error('Error listing streams:', error);
104174
return res.status(500).json({ error: 'Internal server error' });
@@ -184,9 +254,9 @@ export const getStreamEvents = async (req: Request, res: Response) => {
184254

185255
const whereClause: any = { streamId: parsedStreamId };
186256
if (eventType) {
187-
const validEventTypes = ['CREATED', 'TOPPED_UP', 'WITHDRAWN', 'CANCELLED', 'COMPLETED', 'PAUSED', 'RESUMED'];
257+
const validEventTypes = ['CREATED', 'TOPPED_UP', 'WITHDRAWN', 'CANCELLED', 'COMPLETED', 'PAUSED', 'RESUMED', 'FEE_COLLECTED'];
188258
if (!validEventTypes.includes(eventType)) {
189-
return res.status(400).json({
259+
return res.status(400).json({
190260
error: 'Invalid eventType parameter',
191261
message: `eventType must be one of: ${validEventTypes.join(', ')}`
192262
});
@@ -251,8 +321,12 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => {
251321
ratePerSecond: true,
252322
depositedAmount: true,
253323
withdrawnAmount: true,
324+
startTime: true,
254325
lastUpdateTime: true,
255326
isActive: true,
327+
isPaused: true,
328+
pausedAt: true,
329+
totalPausedDuration: true,
256330
updatedAt: true,
257331
},
258332
});
@@ -302,7 +376,7 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => {
302376
*/
303377
export const getUserStreamSummary = async (req: Request, res: Response) => {
304378
try {
305-
const address = (req.params.address ?? '').trim();
379+
const address = Array.isArray(req.params.address) ? req.params.address[0] : (req.params.address ?? '').trim();
306380
if (!address) {
307381
return res.status(400).json({ error: 'Address is required' });
308382
}
@@ -331,18 +405,22 @@ export const getUserStreamSummary = async (req: Request, res: Response) => {
331405
ratePerSecond: true,
332406
depositedAmount: true,
333407
withdrawnAmount: true,
408+
startTime: true,
334409
lastUpdateTime: true,
335410
isActive: true,
411+
isPaused: true,
412+
pausedAt: true,
413+
totalPausedDuration: true,
336414
updatedAt: true,
337415
},
338416
}),
339417
]);
340418

341419
const totalStreamsCreated = outgoingStreams.length;
342-
const totalStreamedOut = sumStringI128(outgoingStreams.map((stream) => stream.withdrawnAmount));
343-
const totalStreamedIn = sumStringI128(incomingStreams.map((stream) => stream.withdrawnAmount));
344-
const activeOutgoingCount = outgoingStreams.filter((stream) => stream.isActive).length;
345-
const activeIncomingCount = incomingStreams.filter((stream) => stream.isActive).length;
420+
const totalStreamedOut = sumStringI128(outgoingStreams.map((stream: any) => stream.withdrawnAmount));
421+
const totalStreamedIn = sumStringI128(incomingStreams.map((stream: any) => stream.withdrawnAmount));
422+
const activeOutgoingCount = outgoingStreams.filter((stream: any) => stream.isActive).length;
423+
const activeIncomingCount = incomingStreams.filter((stream: any) => stream.isActive).length;
346424

347425
const calculatedAt = Math.floor(nowMs / 1000);
348426
let claimableTotal = 0n;

backend/src/lib/redis.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,26 @@
1-
import type { Redis } from 'ioredis';
2-
import RedisClass from 'ioredis';
1+
const Redis = require('ioredis');
32
import logger from '../logger.js';
43

54
const REDIS_URL = process.env.REDIS_URL;
65

7-
let _publisher: Redis | null = null;
8-
let _subscriber: Redis | null = null;
6+
let _publisher: typeof Redis | null = null;
7+
let _subscriber: typeof Redis | null = null;
98
let _available = false;
109

11-
export function getPublisher(): Redis | null {
10+
export function getPublisher(): typeof Redis | null {
1211
return _publisher;
1312
}
1413

15-
export function getSubscriber(): Redis | null {
14+
export function getSubscriber(): typeof Redis | null {
1615
return _subscriber;
1716
}
1817

1918
export function isRedisAvailable(): boolean {
2019
return _available;
2120
}
2221

23-
function makeClient(url: string): Redis {
24-
return new RedisClass(url, {
22+
function makeClient(url: string): typeof Redis {
23+
return new Redis(url, {
2524
maxRetriesPerRequest: 3,
2625
retryStrategy: (times: number) =>
2726
times > 3 ? null : Math.min(times * 200, 2000),

0 commit comments

Comments
 (0)