Skip to content

Commit fbcb15b

Browse files
Merge branch 'main' into fix/wallet-connect-freighter-error-tests
2 parents 59cb70a + 68c6e09 commit fbcb15b

91 files changed

Lines changed: 6783 additions & 828 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,16 @@ jobs:
192192
run: cargo test
193193
working-directory: contracts
194194

195+
- name: Cache cargo-tarpaulin
196+
id: tarpaulin-cache
197+
uses: actions/cache@v4
198+
with:
199+
path: ~/.cargo/bin/cargo-tarpaulin
200+
key: cargo-tarpaulin-${{ runner.os }}-0.37.2
201+
195202
- name: Install cargo-tarpaulin
196-
run: cargo install cargo-tarpaulin --locked
203+
if: steps.tarpaulin-cache.outputs.cache-hit != 'true'
204+
run: cargo install cargo-tarpaulin@0.37.2 --locked
197205

198206
- name: Run Contract Coverage
199207
run: cargo tarpaulin --workspace --out Xml --output-dir coverage --fail-under 70

.github/workflows/security.yml

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88
push:
99
branches: [ main, develop ]
1010
pull_request:
11-
branches: [ main ]
11+
branches: [ main, develop ]
1212
schedule:
1313
- cron: '0 2 * * 0'
1414

@@ -41,6 +41,24 @@ jobs:
4141
- name: Check for known vulnerabilities in backend (production dependencies)
4242
run: npm audit --workspace=backend --omit=dev --audit-level=critical
4343

44+
- name: Setup Rust toolchain for contract audit
45+
uses: dtolnay/rust-toolchain@stable
46+
47+
- name: Cache cargo-audit
48+
id: cargo-audit-cache
49+
uses: actions/cache@v4
50+
with:
51+
path: ~/.cargo/bin/cargo-audit
52+
key: cargo-audit-${{ runner.os }}
53+
54+
- name: Install cargo-audit
55+
if: steps.cargo-audit-cache.outputs.cache-hit != 'true'
56+
run: cargo install cargo-audit --locked
57+
58+
- name: Check for known vulnerabilities in smart contracts (cargo audit)
59+
run: cargo audit
60+
working-directory: contracts
61+
4462
- name: Verify security setup
4563
run: npm run verify-security
4664

@@ -55,19 +73,40 @@ jobs:
5573
strategy:
5674
fail-fast: false
5775
matrix:
58-
language: [ 'javascript', 'typescript' ]
76+
language: [ 'javascript', 'typescript', 'rust' ]
5977

6078
steps:
6179
- name: Checkout repository
6280
uses: actions/checkout@v4
6381

82+
- name: Setup Rust toolchain
83+
if: matrix.language == 'rust'
84+
uses: dtolnay/rust-toolchain@stable
85+
with:
86+
toolchain: stable
87+
targets: wasm32-unknown-unknown
88+
components: clippy
89+
90+
- name: Rust Cache
91+
if: matrix.language == 'rust'
92+
uses: Swatinem/rust-cache@v2
93+
with:
94+
workspace: "contracts -> target"
95+
6496
- name: Initialize CodeQL
6597
uses: github/codeql-action/init@v3
6698
with:
6799
languages: ${{ matrix.language }}
68100

101+
- name: Build Rust contracts for CodeQL
102+
if: matrix.language == 'rust'
103+
run: cargo check --workspace --all-targets
104+
working-directory: contracts
105+
69106
- name: Autobuild
107+
if: matrix.language != 'rust'
70108
uses: github/codeql-action/autobuild@v3
71109

72110
- name: Perform CodeQL Analysis
73111
uses: github/codeql-action/analyze@v3
112+

backend/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ REDIS_URL=
8888
# Time in milliseconds between periodic sweeps to prune expired memory cache entries (default: 60000)
8989
MEMORY_CACHE_SWEEP_MS=60000
9090

91+
# Maximum number of entries kept in the in-memory cache. When exceeded, the
92+
# least-recently-used entries are evicted immediately so memory stays bounded
93+
# even between sweeps (default: 10000)
94+
MEMORY_CACHE_MAX_ITEMS=10000
95+
9196
# Cache time-to-live (TTL) for claimable amount calculations in milliseconds (default: 5000)
9297
CLAIMABLE_CACHE_TTL_MS=5000
9398

backend/docs/SSE_ARCHITECTURE.md

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,27 @@
33
## System Flow
44

55
```
6-
┌─────────────────┐
7-
│ Blockchain │
8-
│ Indexer │
9-
│ (Stellar) │
10-
└────────┬────────┘
11-
│ Events
12-
6+
┌─────────────────────────────────────────────────────────┐
7+
│ Stellar Blockchain / Soroban │
8+
│ - On-chain stream contract executions & ledger events │
9+
└────────────────────────────┬────────────────────────────┘
10+
│ On-Chain Events (via Soroban RPC poll)
11+
1312
┌─────────────────────────────────────────────────────────┐
1413
│ Backend Server │
1514
│ │
1615
│ ┌──────────────────────────────────────────────────┐ │
17-
│ │ Stream Controller │ │
18-
│ │ - Creates/updates streams │ │
19-
│ │ - Calls sseService.broadcast() │ │
16+
│ │ Soroban Event Worker (Indexer) │ │
17+
│ │ - Polls Soroban RPC for confirmed events │ │
18+
│ │ - Persists stream state & events to Database │ │
19+
│ │ - Calls sseService.broadcastToStream/Admin() │ │
2020
│ └──────────────┬───────────────────────────────────┘ │
21-
│ │
21+
│ │ Dispatch events (indexer-driven)
2222
│ ▼ │
2323
│ ┌──────────────────────────────────────────────────┐ │
2424
│ │ SSE Service │ │
2525
│ │ - Manages client connections │ │
26-
│ │ - Filters by subscription │ │
26+
│ │ - Filters by subscription (stream/user/admin) │ │
2727
│ │ - Broadcasts to matching clients │ │
2828
│ └──────────────┬───────────────────────────────────┘ │
2929
│ │ │
@@ -40,6 +40,8 @@
4040
└─────────────────────────────────────┘
4141
```
4242

43+
> **Note on Indexer-Driven Event Origin**: SSE broadcast events originate asynchronously from the background indexer worker (`SorobanEventWorker`) only after transaction confirmation on the Stellar ledger, not synchronously from HTTP API controllers (`stream.controller.ts`, etc.). When a user submits an action (create, pause, withdraw, top-up, cancel), API controllers do not broadcast SSE events directly; events are dispatched once the Soroban event is polled and confirmed on-chain. Additionally, background workers like `StreamRunwayWorker` may dispatch computed alerts (e.g. `STREAM_LOW_BALANCE`).
44+
4345
## Connection Flow
4446

4547
```
@@ -72,7 +74,7 @@ Client Server
7274
│ [Auto Reconnect - 1s] │
7375
│ │
7476
│ GET /events/subscribe │
75-
├──────────────────────────────>│
77+
├──────────────────────────────>│
7678
│ │
7779
│ 200 OK │
7880
│<──────────────────────────────┤
@@ -122,30 +124,37 @@ Client Server
122124
└─────────────────┘
123125
124126
Flow:
125-
1. Backend 1 receives stream creation
127+
1. Backend 1 (SorobanEventWorker) indexes confirmed on-chain event
126128
2. Backend 1 publishes to Redis: "stream-events"
127-
3. All backends (1, 2, 3) receive message
129+
3. All backends (1, 2, 3) receive message via Redis subscriber
128130
4. Each backend broadcasts to its connected clients
129131
5. Total: 33 clients receive the event
130132
```
131133

132134
## Event Broadcasting Logic
133135

134136
```typescript
135-
// Broadcast to specific stream
137+
// Broadcast to specific stream (called by SorobanEventWorker / StreamRunwayWorker)
136138
sseService.broadcastToStream("123", "stream.created", data)
137139
138140
Filter clients: subscription includes "123" or "*"
139141
140142
Send to matching clients
141143

142-
// Broadcast to user
143-
sseService.broadcastToUser("GABC...", "stream.created", data)
144+
// Broadcast to user (called by StreamRunwayWorker)
145+
sseService.broadcastToUser("GABC...", "STREAM_LOW_BALANCE", data)
144146
145147
Filter clients: subscription includes "user:GABC..." or "*"
146148
147149
Send to matching clients
148150

151+
// Broadcast to admin (called by SorobanEventWorker for admin/fee events)
152+
sseService.broadcastToAdmin("stream.fee_config_updated", data)
153+
154+
Filter clients: admin subscribers ("admin" or "*")
155+
156+
Send to matching clients
157+
149158
// Broadcast to all
150159
sseService.broadcast("stream.created", data)
151160

backend/docs/SSE_IMPLEMENTATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ import Redis from 'ioredis';
193193
const redis = new Redis(process.env.REDIS_URL);
194194
const subscriber = new Redis(process.env.REDIS_URL);
195195

196-
// Publisher (in stream controller)
196+
// Publisher (in Soroban event worker / indexer)
197197
redis.publish('stream-events', JSON.stringify({
198198
event: 'stream.created',
199199
data: mockStream,

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"main": "index.js",
77
"scripts": {
88
"prebuild": "prisma generate",
9+
"pretest": "prisma generate",
910
"test": "vitest run",
1011
"test:unit": "vitest run --exclude='tests/integration/**'",
1112
"test:integration": "vitest run tests/integration",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- DropIndex
2+
DROP INDEX IF EXISTS "Stream_streamId_idx";

backend/prisma/schema.prisma

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ model Stream {
5252
@@index([sender])
5353
@@index([recipient])
5454
@@index([tokenAddress])
55-
@@index([streamId])
5655
@@index([isActive])
5756
@@index([isPaused])
5857
}

backend/src/controllers/sse.controller.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ const subscribeSchema = z.object({
1111
all: z.boolean().optional().default(false),
1212
});
1313

14+
/**
15+
* Issue #1246: hard cap on the number of streams fetched per SSE session.
16+
* Prevents unbounded DB queries when a wallet has thousands of streams.
17+
* Users who exceed this cap still receive events for the most recent streams;
18+
* the frontend can fall back to polling or pagination for the remainder.
19+
*/
20+
const MAX_SSE_STREAMS = 500;
21+
1422

1523
function getClientIp(req: Request): string {
1624
const forwarded = req.headers['x-forwarded-for'];
@@ -48,8 +56,13 @@ export const subscribe = async (req: Request, res: Response) => {
4856
// Consistent with GET /v1/events/ (which requires requireAuth and is scoped to user's address),
4957
// SSE subscriptions are also restricted to streams owned by the authenticated user.
5058
// Scope: only streams where the authenticated user is sender or recipient
59+
// Issue #1246: cap the query to prevent unbounded fetches on reconnect.
60+
// Ordered by startTime desc so the most recent streams are always included
61+
// when the cap is reached.
5162
const ownedStreams = await prisma.stream.findMany({
5263
where: { OR: [{ sender: publicKey }, { recipient: publicKey }] },
64+
orderBy: { startTime: "desc" },
65+
take: MAX_SSE_STREAMS,
5366
select: { streamId: true, sender: true, recipient: true },
5467
});
5568
const ownedIds = new Set(ownedStreams.map((s: { streamId: bigint }) => String(s.streamId)));

backend/src/controllers/stream.controller.ts

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ import {
2222
const DEFAULT_STREAM_PAGE_SIZE = 20;
2323
const MAX_STREAM_PAGE_SIZE = 100;
2424

25+
/**
26+
* Hard cap on the number of streams fetched per user in the summary endpoint.
27+
* Prevents unbounded DB queries when a wallet has thousands of streams.
28+
* Users who exceed this cap receive a truncated summary (counts and totals
29+
* reflect only the most recent streams) plus a `truncated` flag so the
30+
* frontend can offer a pagination or export fallback.
31+
*/
32+
export const MAX_USER_STREAMS = 500;
33+
2534
interface UserStreamSummary {
2635
address: string;
2736
totalStreamsCreated: number;
@@ -590,9 +599,15 @@ export const getUserStreamSummary = async (
590599

591600
pruneUserSummaryCache(nowMs);
592601

602+
// Issue #1246: cap the number of streams fetched per direction to prevent
603+
// unbounded DB queries. Power users with more than MAX_USER_STREAMS
604+
// streams receive a truncated summary (the `truncated` flag lets the
605+
// frontend offer a pagination/export fallback).
593606
const [outgoingStreams, incomingStreams] = await Promise.all([
594607
prisma.stream.findMany({
595608
where: { sender: address },
609+
orderBy: { startTime: "desc" },
610+
take: MAX_USER_STREAMS,
596611
select: {
597612
streamId: true,
598613
ratePerSecond: true,
@@ -609,6 +624,8 @@ export const getUserStreamSummary = async (
609624
}),
610625
prisma.stream.findMany({
611626
where: { recipient: address },
627+
orderBy: { startTime: "desc" },
628+
take: MAX_USER_STREAMS,
612629
select: {
613630
streamId: true,
614631
ratePerSecond: true,
@@ -651,15 +668,20 @@ export const getUserStreamSummary = async (
651668
(stream: any) => stream.isActive,
652669
).length;
653670

654-
const summary: UserStreamSummary = {
671+
const truncated =
672+
outgoingStreams.length >= MAX_USER_STREAMS ||
673+
incomingStreams.length >= MAX_USER_STREAMS;
674+
675+
const summary = {
655676
address,
656677
totalStreamsCreated,
657678
totalStreamedOut,
658679
totalStreamedIn,
659680
currentClaimable: claimableInTotal.toString(),
660681
activeOutgoingCount,
661682
activeIncomingCount,
662-
};
683+
...(truncated ? { truncated: true } : {}),
684+
} satisfies UserStreamSummary & { truncated?: boolean };
663685

664686
userSummaryCache.set(cacheKey, {
665687
value: summary,
@@ -730,19 +752,24 @@ export const topUpStreamHandler = async (req: Request, res: Response) => {
730752

731753
const txHash = await topUpStream(streamId, amount, callerAddress);
732754

733-
const newDeposited = (BigInt(stream.depositedAmount) + amount).toString();
734-
await prisma.stream.update({
735-
where: { streamId },
736-
data: {
737-
depositedAmount: newDeposited,
738-
lastUpdateTime: BigInt(Math.floor(Date.now() / 1000)),
739-
},
740-
});
755+
// Use raw SQL atomic increment to prevent concurrent top-ups from
756+
// overwriting each other's updates (Issue #1217 — read-compute-write race).
757+
// Prisma's built-in { increment } is unavailable on String-typed columns,
758+
// so we perform SET deposited_amount = deposited_amount + $1::bigint
759+
// directly in a single SQL statement.
760+
const now = BigInt(Math.floor(Date.now() / 1000));
761+
await prisma.$executeRawUnsafe(
762+
`UPDATE "Stream" SET "depositedAmount" = ("depositedAmount"::bigint + $1::bigint)::text, "lastUpdateTime" = $2 WHERE "streamId" = $3`,
763+
amount.toString(),
764+
now,
765+
streamId,
766+
);
767+
const updatedStream = await prisma.stream.findUnique({ where: { streamId } });
741768

742769
logger.info(`[topUp] stream=${streamId} amount=${amount} txHash=${txHash}`);
743770
return res
744771
.status(200)
745-
.json({ streamId, txHash, depositedAmount: newDeposited });
772+
.json({ streamId, txHash, depositedAmount: updatedStream!.depositedAmount });
746773
} catch (error: any) {
747774
logger.error(`[topUp] stream=${streamId} error:`, error);
748775
return res.status(400).json({ error: 'Failed to top up stream on chain', message: error.message ?? 'Unknown error' });

0 commit comments

Comments
 (0)