diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdcec16..70d1ed8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,3 +116,30 @@ jobs: - name: Run Contract Tests run: cargo test working-directory: contracts + + - name: Install Stellar CLI + run: | + curl -fsSL https://github.com/stellar/stellar-cli/raw/main/install.sh | sh -s -- --install-deps + shell: bash + + - name: Optimize WASM files + run: | + set -euo pipefail + WASMS=$(find contracts/target -type f -name "*.wasm" -print) + if [ -z "$WASMS" ]; then + echo "No wasm files found" + exit 1 + fi + for w in $WASMS; do + out="${w%%.wasm}.optimized.wasm" + echo "Optimizing $w -> $out" + stellar contract optimize --wasm "$w" --wasm-out "$out" + done + shell: bash + + - name: Upload optimized WASM artifacts + uses: actions/upload-artifact@v4 + with: + name: optimized-wasm + path: | + contracts/target/**/**/*.optimized.wasm diff --git a/backend/.env.example b/backend/.env.example index 1d388c2..9d90cc9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,6 +5,8 @@ DATABASE_URL="postgresql://user:password@localhost:5432/flowfi?schema=public" PORT=3001 NODE_ENV=development CORS_ALLOWED_ORIGINS="https://app.flowfi.xyz,https://flowfi.xyz" +# Comma-separated list of allowed origins for CORS. In development, if unset, +# defaults to http://localhost:3000 # Stellar Network (Testnet/Mainnet) STELLAR_NETWORK=testnet diff --git a/backend/src/app.ts b/backend/src/app.ts index 3f59c38..94dc38e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -11,11 +11,17 @@ import healthRoutes from './routes/health.routes.js'; const app = express(); const isProduction = process.env.NODE_ENV === 'production'; -const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS ?? '') +const rawCors = process.env.CORS_ALLOWED_ORIGINS ?? ''; +const allowedOrigins = rawCors .split(',') .map((origin) => origin.trim()) .filter(Boolean); +// Default in development to only localhost:3000 (frontend dev server) +if (!process.env.CORS_ALLOWED_ORIGINS && !isProduction) { + allowedOrigins.push('http://localhost:3000'); +} + // Apply global rate limiter first app.use(globalRateLimiter); @@ -37,11 +43,6 @@ app.use((req: Request, res: Response, next: NextFunction) => { app.use(cors({ origin(origin, callback) { - if (!isProduction) { - callback(null, true); - return; - } - // Allow non-browser clients (no Origin header) if (!origin) { callback(null, true); @@ -53,10 +54,20 @@ app.use(cors({ return; } + // Not allowed callback(new Error('CORS origin not allowed')); }, credentials: true, })); + +// Convert CORS errors into 403 responses so callers get a clear status code +app.use((err: any, req: Request, res: Response, next: NextFunction) => { + if (err && err.message === 'CORS origin not allowed') { + res.status(403).json({ error: 'CORS origin not allowed' }); + return; + } + next(err); +}); app.use(express.json()); // Sandbox mode detection (before versioning) diff --git a/backend/src/controllers/sse.controller.ts b/backend/src/controllers/sse.controller.ts index 6f8eb6c..360ec26 100644 --- a/backend/src/controllers/sse.controller.ts +++ b/backend/src/controllers/sse.controller.ts @@ -9,6 +9,7 @@ const subscribeSchema = z.object({ all: z.boolean().optional().default(false), }); + function getClientIp(req: Request): string { const forwarded = req.headers['x-forwarded-for']; if (typeof forwarded === 'string' && forwarded.trim().length > 0) { diff --git a/backend/src/lib/redis.ts b/backend/src/lib/redis.ts index 9f269a7..6f748d9 100644 --- a/backend/src/lib/redis.ts +++ b/backend/src/lib/redis.ts @@ -1,4 +1,5 @@ -import { Redis } from 'ioredis'; +import type { Redis } from 'ioredis'; +import RedisClass from 'ioredis'; import logger from '../logger.js'; const REDIS_URL = process.env.REDIS_URL; @@ -20,9 +21,10 @@ export function isRedisAvailable(): boolean { } function makeClient(url: string): Redis { - return new Redis(url, { + return new RedisClass(url, { maxRetriesPerRequest: 3, - retryStrategy: (times: number) => (times > 3 ? null : Math.min(times * 200, 2000)), + retryStrategy: (times: number) => + times > 3 ? null : Math.min(times * 200, 2000), enableOfflineQueue: false, lazyConnect: true, }); @@ -42,9 +44,14 @@ export async function connectRedis(): Promise { _publisher = publisher; _subscriber = subscriber; _available = true; + logger.info('[Redis] Connected — horizontal SSE scaling enabled.'); } catch (err) { - logger.warn('[Redis] Connection failed — falling back to single-instance SSE mode:', err); + logger.warn( + '[Redis] Connection failed — falling back to single-instance SSE mode:', + err + ); + _publisher?.disconnect(); _subscriber?.disconnect(); _publisher = null; @@ -58,4 +65,4 @@ export async function disconnectRedis(): Promise { _publisher = null; _subscriber = null; _available = false; -} +} \ No newline at end of file diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index 6315362..4f95624 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -3,7 +3,7 @@ import streamRoutes from './stream.routes.js'; import eventsRoutes from './events.routes.js'; import userRoutes from './user.routes.js'; import authRoutes from './auth.routes.js'; -import v1AdminRoutes from './admin.routes.js'; +import adminRoutes from './admin.routes.js'; import adminMetricsRoutes from '../adminRoutes.js'; const router = Router(); @@ -13,7 +13,9 @@ router.use('/streams', streamRoutes); router.use('/events', eventsRoutes); router.use('/users', userRoutes); router.use('/auth', authRoutes); -router.use('/admin', v1AdminRoutes); -router.use('/admin', adminMetricsRoutes); -export default router; +// Admin routes +router.use('/admin', adminRoutes); +router.use('/admin/metrics', adminMetricsRoutes); + +export default router; \ No newline at end of file diff --git a/backend/src/services/sorobanService.ts b/backend/src/services/sorobanService.ts index 81dbb05..4c4e842 100644 --- a/backend/src/services/sorobanService.ts +++ b/backend/src/services/sorobanService.ts @@ -1,4 +1,4 @@ -import { rpc, xdr, StrKey, Contract, Address, nativeToScVal } from '@stellar/stellar-sdk'; +import { rpc, xdr, StrKey, Contract, nativeToScVal } from '@stellar/stellar-sdk'; import logger from '../logger.js'; const RPC_URL = process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org'; @@ -45,29 +45,53 @@ function decodeMap(val: xdr.ScVal): Record { async function simulateContractCall(method: string, args: xdr.ScVal[]): Promise { const contract = new Contract(CONTRACT_ID); + const op = contract.call(method, ...args); - const tx = new (await import('@stellar/stellar-sdk')).TransactionBuilder( - new (await import('@stellar/stellar-sdk')).Account('GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', '0'), - { fee: '100', networkPassphrase: process.env.STELLAR_NETWORK === 'mainnet' - ? (await import('@stellar/stellar-sdk')).Networks.PUBLIC - : (await import('@stellar/stellar-sdk')).Networks.TESTNET } - ).addOperation(op).setTimeout(30).build(); + + const { TransactionBuilder, Account, Networks } = await import('@stellar/stellar-sdk'); + + const tx = new TransactionBuilder( + new Account( + 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', + '0' + ), + { + fee: '100', + networkPassphrase: + process.env.STELLAR_NETWORK === 'mainnet' + ? Networks.PUBLIC + : Networks.TESTNET, + } + ) + .addOperation(op) + .setTimeout(30) + .build(); const result = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(result)) { throw new Error(`Simulation error: ${result.error}`); } + const simSuccess = result as rpc.Api.SimulateTransactionSuccessResponse; return simSuccess.result!.retval; } export async function getStreamFromChain(streamId: number): Promise { if (!CONTRACT_ID) return null; + try { const retval = await simulateContractCall('get_stream', [ nativeToScVal(streamId, { type: 'u64' }), ]); + const fields = decodeMap(retval); + + const isActiveVal = fields['is_active']!; + const isActive = + isActiveVal.switch().value === xdr.ScValType.scvBool().value && + isActiveVal.b() === true; + return { streamId, sender: decodeAddress(fields['sender']!), @@ -77,7 +101,7 @@ export async function getStreamFromChain(streamId: number): Promise { if (!CONTRACT_ID) return null; + try { const retval = await simulateContractCall('get_claimable_amount', [ nativeToScVal(streamId, { type: 'u64' }), ]); + return decodeI128(retval); } catch (err) { logger.error(`[SorobanService] getClaimableFromChain(${streamId}) failed:`, err); @@ -101,4 +127,4 @@ export async function getClaimableFromChain(streamId: number): Promise STALE_THRESHOLD_MS; -} +} \ No newline at end of file diff --git a/backend/src/workers/soroban-event-worker.ts b/backend/src/workers/soroban-event-worker.ts index 6fc7c76..dc5c877 100644 --- a/backend/src/workers/soroban-event-worker.ts +++ b/backend/src/workers/soroban-event-worker.ts @@ -123,12 +123,16 @@ export class SorobanEventWorker { if (this.activeBatch) await this.activeBatch; } - /** Trigger an immediate poll cycle (used for replay functionality). */ + /** Trigger an immediate poll cycle (used for replay and manual updates). */ async triggerPoll(): Promise { if (!this.isRunning) return; - await this.fetchAndProcessEvents().catch((err) => { + + try { + await this.fetchAndProcessEvents(); + } catch (err) { logger.error('[SorobanWorker] Manual poll error:', err); - }); + } + } } // ─── Internal ────────────────────────────────────────────────────────────── diff --git a/backend/tests/cors.test.ts b/backend/tests/cors.test.ts new file mode 100644 index 0000000..53ae7f5 --- /dev/null +++ b/backend/tests/cors.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import app from '../src/app.js'; + +describe('CORS middleware', () => { + it('returns 403 for non-whitelisted origin', async () => { + const response = await request(app) + .get('/') + .set('Origin', 'https://evil.example') + .set('Accept', 'text/plain'); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('CORS origin not allowed'); + }); +}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9316178..5ffea1e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,19 @@ This document explains how FlowFi moves data from on-chain contract events into API responses and real-time frontend updates. -## High-Level Pipeline +# FlowFi Architecture + +This document explains how FlowFi moves data from on-chain contract events into API responses and real-time frontend updates. + +## High-Level Overview + +```mermaid +flowchart LR + Contract[Stream Contract (Soroban WASM)] --> Indexer[Soroban Event Indexer] + Indexer --> DB[(Postgres DB)] + DB --> API[Backend API (Express + SSE)] + API --> UI[Frontend (Next.js)] + UI --> API ```mermaid flowchart LR diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f0a6264..2b1bb4f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,133 +1,138 @@ -# Development Onboarding +````md +# Development Guide This guide is intended to let a new contributor run the full FlowFi stack from a fresh clone. +--- + ## Prerequisites Required: -1. Node.js 20.x and npm -1. Rust toolchain (rustup + cargo) -1. PostgreSQL 14+ +- Rust toolchain (stable via rustup) +- Node.js 20+ +- npm +- PostgreSQL 14+ +- Docker & Docker Compose (recommended for local infra) +- Stellar CLI / Soroban CLI (https://github.com/stellar/stellar-cli) + +Optional: -Recommended: +- Redis 7+ (for multi-instance SSE testing) -1. Stellar CLI / Soroban CLI -1. Redis 7+ (optional for multi-instance SSE testing) -1. Docker + Docker Compose (easiest local infra) +--- -## 1) Clone and Install +## Quick Start (Recommended) + +### 1. Clone repository ```bash git clone https://github.com/LabsCrypt/flowfi.git cd flowfi -``` +```` -Install root helpers (if any): - -```bash -npm install -``` +--- -Install backend + frontend dependencies: +### 2. Start infrastructure ```bash -cd backend && npm install -cd ../frontend && npm install -cd .. +docker compose up -d postgres ``` -## 2) Start Infrastructure - -### Option A: Docker (recommended) +(Optional for SSE fanout testing) ```bash -docker compose up -d postgres +docker compose up -d redis ``` -If your compose file includes Redis and you want to test pub/sub SSE fanout: +--- + +### 3. Backend setup ```bash -docker compose up -d redis +cd backend +npm install +cp .env.example .env ``` -### Option B: Local services +Configure `.env`: -Run PostgreSQL locally and create/update your database for `DATABASE_URL`. -Run Redis locally only if needed. +* DATABASE_URL +* JWT_SECRET +* STELLAR_NETWORK=testnet +* REDIS_URL (optional) -## 3) Configure Environment - -Create backend env file (example values): +Run database setup: ```bash -cd backend -cp .env.example .env 2>/dev/null || true +npm run prisma:generate +npm run prisma:migrate ``` -Set at least: +Start backend: -1. `DATABASE_URL=postgresql://...` -1. `JWT_SECRET=...` -1. `STELLAR_NETWORK=testnet` -1. `MAX_SSE_CONNECTIONS=10000` +```bash +npm run dev +``` -Optional Redis for multi-instance SSE: +Backend runs at: -1. `REDIS_URL=redis://localhost:6379` +* [http://localhost:3001/v1](http://localhost:3001/v1) +* [http://localhost:3001/health](http://localhost:3001/health) -Return to repo root after editing env. +--- -## 4) Prepare Database +### 4. Frontend setup ```bash -cd backend -npm run prisma:generate -npm run prisma:migrate +cd frontend +npm install +npm run dev ``` -Optional seed: +Frontend: -```bash -npm run prisma:seed -``` +* [http://localhost:3000](http://localhost:3000) + +--- -## 5) Build and Run Contracts (optional for UI/API iteration, required for full chain flow) +### 5. Contracts (optional) ```bash cd contracts cargo build --target wasm32-unknown-unknown --release +cargo test ``` -If deploying/testing contracts with CLI, ensure Stellar CLI is configured for testnet. +--- -## 6) Run Backend +## Full Stack Setup (Detailed Mode) + +### Backend ```bash cd backend +npm ci npm run dev ``` -Backend endpoints: - -1. API base: `http://localhost:3001/v1` -1. Swagger UI: `http://localhost:3001/api-docs` -1. Health: `http://localhost:3001/health` - -## 7) Run Frontend - -In a second terminal: +### Frontend ```bash cd frontend +npm ci npm run dev ``` -Frontend app: +### Database + +```bash +docker compose up -d postgres +``` -1. `http://localhost:3000` +--- -## 8) Run Tests +## Running Tests Backend: @@ -136,65 +141,109 @@ cd backend npm test ``` -Frontend lint: +Frontend: ```bash cd frontend npm run lint ``` +Contracts: + +```bash +cd contracts +cargo test +``` + +--- + +## Testnet vs Local Mode + +Configure in `.env`: + +* `STELLAR_NETWORK=testnet` +* `SANDBOX_MODE_ENABLED=true` (optional) +* `STELLAR_HORIZON_URL` (if needed) + +--- + ## Common Issues ### Indexer not syncing -Symptoms: +* Ensure worker/indexer is running +* Confirm correct Stellar network (testnet/mainnet) +* Check DB cursor/state +* Review logs for RPC/Horizon errors + +--- + +### SSE issues + +* Verify JWT token validity +* Check `/v1/events/stats` +* Ensure Redis is running (multi-instance mode) +* Confirm connection limits are not exceeded + +--- + +### Auth failures (401/403) + +* Ensure wallet signature matches public key +* Verify `JWT_SECRET` +* Confirm Bearer token is included -1. Streams created on-chain do not appear in dashboard. +--- -Checks: +### Database migration issues -1. Confirm backend worker/indexer is running. -1. Verify Stellar network config (`testnet` vs `mainnet`) matches your transactions. -1. Verify `IndexerState` row updates in DB. -1. Check backend logs for RPC/Horizon throttling or cursor errors. +* Check `DATABASE_URL` +* Run `prisma generate` +* Reset DB if schema drift occurs -### SSE drops or reconnect loops +--- -Symptoms: +## Suggested Day-1 Flow -1. Live updates stop; browser reconnects repeatedly. +1. Start Postgres (and Redis if needed) +2. Run backend migrations +3. Start backend +4. Start frontend +5. Create a stream and verify SSE updates -Checks: +--- -1. Verify JWT is valid and unexpired. -1. Check `/v1/events/stats` for capacity limits. -1. Confirm per-IP limit not exceeded (6th SSE connection returns 429). -1. In multi-instance deployments, ensure Redis pub/sub connectivity on all instances. +## Legacy Quick Setup (Minimal) -### Auth errors (401/403) +```bash +docker compose up -d +cd backend && npm ci && npm run dev +cd frontend && npm ci && npm run dev +``` -Symptoms: +--- -1. `Unauthorized` during subscribe or protected endpoint calls. +## Contracts Build Only -Checks: +```bash +cd contracts +cargo build --target wasm32-unknown-unknown --release +``` -1. Sign challenge using the same wallet/public key you verify. -1. Ensure backend `JWT_SECRET` is stable across restarts if testing long sessions. -1. Confirm `Authorization: Bearer ` header is present. +--- -### Prisma or DB migration failures +## Links -Checks: +* Architecture: `ARCHITECTURE.md` +* Backend: `backend/` +* Frontend: `frontend/` +* Contracts: `contracts/stream_contract` -1. Ensure `DATABASE_URL` points to a reachable DB. -1. Reset local DB and rerun migrations if schema drift occurred. -1. Regenerate Prisma client after schema changes. +``` -## Suggested Day-1 Workflow +--- -1. Start Postgres (and optionally Redis). -1. Run backend migrations. -1. Start backend and open Swagger. -1. Start frontend and connect wallet. -1. Create a stream and verify updates via dashboard + SSE. +If you want next-level polish, I can turn this into: +- a **Makefile / task runner setup (one-command dev start)** +- or a **Dockerized full-stack dev environment (zero manual setup)** +``` diff --git a/package-lock.json b/package-lock.json index ed5d267..9f48adc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "version": "25.3.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -285,6 +286,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -501,6 +503,7 @@ "version": "7.29.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -752,7 +755,8 @@ "node_modules/@electric-sql/pglite": { "version": "0.3.15", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.20", @@ -2131,6 +2135,7 @@ "version": "20.19.33", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2164,6 +2169,7 @@ "version": "19.2.14", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2270,6 +2276,7 @@ "version": "8.56.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2521,6 +2528,7 @@ "version": "8.16.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3012,6 +3020,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3507,7 +3516,8 @@ }, "node_modules/csstype": { "version": "3.2.3", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -4388,6 +4398,7 @@ "version": "9.39.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4557,6 +4568,7 @@ "version": "2.32.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4809,6 +4821,7 @@ "node_modules/express": { "version": "5.2.1", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5488,6 +5501,7 @@ "version": "4.11.4", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -7191,6 +7205,7 @@ "node_modules/pg": { "version": "8.18.0", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -7394,6 +7409,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "7.4.1", "@prisma/dev": "0.20.0", @@ -7560,6 +7576,7 @@ "node_modules/react": { "version": "19.2.4", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7567,6 +7584,7 @@ "node_modules/react-dom": { "version": "19.2.4", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8745,6 +8763,7 @@ "version": "4.0.3", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9018,6 +9037,7 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9622,6 +9642,7 @@ "node_modules/zod": { "version": "4.3.6", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }