Skip to content

Commit 3f9e496

Browse files
authored
Merge pull request #288 from promisszn/main
feat: enhance CI workflow, add CORS handling, and update documentation
2 parents 7adc624 + 8118f16 commit 3f9e496

12 files changed

Lines changed: 305 additions & 128 deletions

File tree

.github/workflows/ci.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,30 @@ jobs:
116116
- name: Run Contract Tests
117117
run: cargo test
118118
working-directory: contracts
119+
120+
- name: Install Stellar CLI
121+
run: |
122+
curl -fsSL https://github.com/stellar/stellar-cli/raw/main/install.sh | sh -s -- --install-deps
123+
shell: bash
124+
125+
- name: Optimize WASM files
126+
run: |
127+
set -euo pipefail
128+
WASMS=$(find contracts/target -type f -name "*.wasm" -print)
129+
if [ -z "$WASMS" ]; then
130+
echo "No wasm files found"
131+
exit 1
132+
fi
133+
for w in $WASMS; do
134+
out="${w%%.wasm}.optimized.wasm"
135+
echo "Optimizing $w -> $out"
136+
stellar contract optimize --wasm "$w" --wasm-out "$out"
137+
done
138+
shell: bash
139+
140+
- name: Upload optimized WASM artifacts
141+
uses: actions/upload-artifact@v4
142+
with:
143+
name: optimized-wasm
144+
path: |
145+
contracts/target/**/**/*.optimized.wasm

backend/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ DATABASE_URL="postgresql://user:password@localhost:5432/flowfi?schema=public"
55
PORT=3001
66
NODE_ENV=development
77
CORS_ALLOWED_ORIGINS="https://app.flowfi.xyz,https://flowfi.xyz"
8+
# Comma-separated list of allowed origins for CORS. In development, if unset,
9+
# defaults to http://localhost:3000
810

911
# Stellar Network (Testnet/Mainnet)
1012
STELLAR_NETWORK=testnet

backend/src/app.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,17 @@ import healthRoutes from './routes/health.routes.js';
1111

1212
const app = express();
1313
const isProduction = process.env.NODE_ENV === 'production';
14-
const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS ?? '')
14+
const rawCors = process.env.CORS_ALLOWED_ORIGINS ?? '';
15+
const allowedOrigins = rawCors
1516
.split(',')
1617
.map((origin) => origin.trim())
1718
.filter(Boolean);
1819

20+
// Default in development to only localhost:3000 (frontend dev server)
21+
if (!process.env.CORS_ALLOWED_ORIGINS && !isProduction) {
22+
allowedOrigins.push('http://localhost:3000');
23+
}
24+
1925
// Apply global rate limiter first
2026
app.use(globalRateLimiter);
2127

@@ -37,11 +43,6 @@ app.use((req: Request, res: Response, next: NextFunction) => {
3743

3844
app.use(cors({
3945
origin(origin, callback) {
40-
if (!isProduction) {
41-
callback(null, true);
42-
return;
43-
}
44-
4546
// Allow non-browser clients (no Origin header)
4647
if (!origin) {
4748
callback(null, true);
@@ -53,10 +54,20 @@ app.use(cors({
5354
return;
5455
}
5556

57+
// Not allowed
5658
callback(new Error('CORS origin not allowed'));
5759
},
5860
credentials: true,
5961
}));
62+
63+
// Convert CORS errors into 403 responses so callers get a clear status code
64+
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
65+
if (err && err.message === 'CORS origin not allowed') {
66+
res.status(403).json({ error: 'CORS origin not allowed' });
67+
return;
68+
}
69+
next(err);
70+
});
6071
app.use(express.json());
6172

6273
// Sandbox mode detection (before versioning)

backend/src/controllers/sse.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const subscribeSchema = z.object({
99
all: z.boolean().optional().default(false),
1010
});
1111

12+
1213
function getClientIp(req: Request): string {
1314
const forwarded = req.headers['x-forwarded-for'];
1415
if (typeof forwarded === 'string' && forwarded.trim().length > 0) {

backend/src/lib/redis.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Redis } from 'ioredis';
1+
import type { Redis } from 'ioredis';
2+
import RedisClass from 'ioredis';
23
import logger from '../logger.js';
34

45
const REDIS_URL = process.env.REDIS_URL;
@@ -20,9 +21,10 @@ export function isRedisAvailable(): boolean {
2021
}
2122

2223
function makeClient(url: string): Redis {
23-
return new Redis(url, {
24+
return new RedisClass(url, {
2425
maxRetriesPerRequest: 3,
25-
retryStrategy: (times: number) => (times > 3 ? null : Math.min(times * 200, 2000)),
26+
retryStrategy: (times: number) =>
27+
times > 3 ? null : Math.min(times * 200, 2000),
2628
enableOfflineQueue: false,
2729
lazyConnect: true,
2830
});
@@ -42,9 +44,14 @@ export async function connectRedis(): Promise<void> {
4244
_publisher = publisher;
4345
_subscriber = subscriber;
4446
_available = true;
47+
4548
logger.info('[Redis] Connected — horizontal SSE scaling enabled.');
4649
} catch (err) {
47-
logger.warn('[Redis] Connection failed — falling back to single-instance SSE mode:', err);
50+
logger.warn(
51+
'[Redis] Connection failed — falling back to single-instance SSE mode:',
52+
err
53+
);
54+
4855
_publisher?.disconnect();
4956
_subscriber?.disconnect();
5057
_publisher = null;
@@ -58,4 +65,4 @@ export async function disconnectRedis(): Promise<void> {
5865
_publisher = null;
5966
_subscriber = null;
6067
_available = false;
61-
}
68+
}

backend/src/routes/v1/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import streamRoutes from './stream.routes.js';
33
import eventsRoutes from './events.routes.js';
44
import userRoutes from './user.routes.js';
55
import authRoutes from './auth.routes.js';
6-
import v1AdminRoutes from './admin.routes.js';
6+
import adminRoutes from './admin.routes.js';
77
import adminMetricsRoutes from '../adminRoutes.js';
88

99
const router = Router();
@@ -13,7 +13,9 @@ router.use('/streams', streamRoutes);
1313
router.use('/events', eventsRoutes);
1414
router.use('/users', userRoutes);
1515
router.use('/auth', authRoutes);
16-
router.use('/admin', v1AdminRoutes);
17-
router.use('/admin', adminMetricsRoutes);
1816

19-
export default router;
17+
// Admin routes
18+
router.use('/admin', adminRoutes);
19+
router.use('/admin/metrics', adminMetricsRoutes);
20+
21+
export default router;

backend/src/services/sorobanService.ts

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { rpc, xdr, StrKey, Contract, Address, nativeToScVal } from '@stellar/stellar-sdk';
1+
import { rpc, xdr, StrKey, Contract, nativeToScVal } from '@stellar/stellar-sdk';
22
import logger from '../logger.js';
33

44
const RPC_URL = process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org';
@@ -45,29 +45,53 @@ function decodeMap(val: xdr.ScVal): Record<string, xdr.ScVal> {
4545

4646
async function simulateContractCall(method: string, args: xdr.ScVal[]): Promise<xdr.ScVal> {
4747
const contract = new Contract(CONTRACT_ID);
48+
4849
const op = contract.call(method, ...args);
49-
const tx = new (await import('@stellar/stellar-sdk')).TransactionBuilder(
50-
new (await import('@stellar/stellar-sdk')).Account('GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', '0'),
51-
{ fee: '100', networkPassphrase: process.env.STELLAR_NETWORK === 'mainnet'
52-
? (await import('@stellar/stellar-sdk')).Networks.PUBLIC
53-
: (await import('@stellar/stellar-sdk')).Networks.TESTNET }
54-
).addOperation(op).setTimeout(30).build();
50+
51+
const { TransactionBuilder, Account, Networks } = await import('@stellar/stellar-sdk');
52+
53+
const tx = new TransactionBuilder(
54+
new Account(
55+
'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN',
56+
'0'
57+
),
58+
{
59+
fee: '100',
60+
networkPassphrase:
61+
process.env.STELLAR_NETWORK === 'mainnet'
62+
? Networks.PUBLIC
63+
: Networks.TESTNET,
64+
}
65+
)
66+
.addOperation(op)
67+
.setTimeout(30)
68+
.build();
5569

5670
const result = await server.simulateTransaction(tx);
71+
5772
if (rpc.Api.isSimulationError(result)) {
5873
throw new Error(`Simulation error: ${result.error}`);
5974
}
75+
6076
const simSuccess = result as rpc.Api.SimulateTransactionSuccessResponse;
6177
return simSuccess.result!.retval;
6278
}
6379

6480
export async function getStreamFromChain(streamId: number): Promise<ChainStream | null> {
6581
if (!CONTRACT_ID) return null;
82+
6683
try {
6784
const retval = await simulateContractCall('get_stream', [
6885
nativeToScVal(streamId, { type: 'u64' }),
6986
]);
87+
7088
const fields = decodeMap(retval);
89+
90+
const isActiveVal = fields['is_active']!;
91+
const isActive =
92+
isActiveVal.switch().value === xdr.ScValType.scvBool().value &&
93+
isActiveVal.b() === true;
94+
7195
return {
7296
streamId,
7397
sender: decodeAddress(fields['sender']!),
@@ -77,7 +101,7 @@ export async function getStreamFromChain(streamId: number): Promise<ChainStream
77101
depositedAmount: decodeI128(fields['deposited_amount']!),
78102
withdrawnAmount: decodeI128(fields['withdrawn_amount']!),
79103
startTime: Number(fields['start_time']!.u64().toString()),
80-
isActive: fields['is_active']!.switch().value === xdr.ScValType.scvBool().value && fields['is_active']!.b() === true,
104+
isActive,
81105
};
82106
} catch (err) {
83107
logger.error(`[SorobanService] getStreamFromChain(${streamId}) failed:`, err);
@@ -87,10 +111,12 @@ export async function getStreamFromChain(streamId: number): Promise<ChainStream
87111

88112
export async function getClaimableFromChain(streamId: number): Promise<string | null> {
89113
if (!CONTRACT_ID) return null;
114+
90115
try {
91116
const retval = await simulateContractCall('get_claimable_amount', [
92117
nativeToScVal(streamId, { type: 'u64' }),
93118
]);
119+
94120
return decodeI128(retval);
95121
} catch (err) {
96122
logger.error(`[SorobanService] getClaimableFromChain(${streamId}) failed:`, err);
@@ -101,4 +127,4 @@ export async function getClaimableFromChain(streamId: number): Promise<string |
101127
/** Returns true when the DB record is older than STALE_THRESHOLD_MS. */
102128
export function isStale(updatedAt: Date): boolean {
103129
return Date.now() - updatedAt.getTime() > STALE_THRESHOLD_MS;
104-
}
130+
}

backend/src/workers/soroban-event-worker.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,16 @@ export class SorobanEventWorker {
123123
if (this.activeBatch) await this.activeBatch;
124124
}
125125

126-
/** Trigger an immediate poll cycle (used for replay functionality). */
126+
/** Trigger an immediate poll cycle (used for replay and manual updates). */
127127
async triggerPoll(): Promise<void> {
128128
if (!this.isRunning) return;
129-
await this.fetchAndProcessEvents().catch((err) => {
129+
130+
try {
131+
await this.fetchAndProcessEvents();
132+
} catch (err) {
130133
logger.error('[SorobanWorker] Manual poll error:', err);
131-
});
134+
}
135+
}
132136
}
133137

134138
// ─── Internal ──────────────────────────────────────────────────────────────

backend/tests/cors.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, it, expect } from 'vitest';
2+
import request from 'supertest';
3+
import app from '../src/app.js';
4+
5+
describe('CORS middleware', () => {
6+
it('returns 403 for non-whitelisted origin', async () => {
7+
const response = await request(app)
8+
.get('/')
9+
.set('Origin', 'https://evil.example')
10+
.set('Accept', 'text/plain');
11+
12+
expect(response.status).toBe(403);
13+
expect(response.body.error).toBe('CORS origin not allowed');
14+
});
15+
});

docs/ARCHITECTURE.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@
22

33
This document explains how FlowFi moves data from on-chain contract events into API responses and real-time frontend updates.
44

5-
## High-Level Pipeline
5+
# FlowFi Architecture
6+
7+
This document explains how FlowFi moves data from on-chain contract events into API responses and real-time frontend updates.
8+
9+
## High-Level Overview
10+
11+
```mermaid
12+
flowchart LR
13+
Contract[Stream Contract (Soroban WASM)] --> Indexer[Soroban Event Indexer]
14+
Indexer --> DB[(Postgres DB)]
15+
DB --> API[Backend API (Express + SSE)]
16+
API --> UI[Frontend (Next.js)]
17+
UI --> API
618
719
```mermaid
820
flowchart LR

0 commit comments

Comments
 (0)