Skip to content

Commit db408d3

Browse files
authored
Merge branch 'main' into feat/issues-1031-1030-1029-1028
2 parents e5113ab + 4bd2cc2 commit db408d3

87 files changed

Lines changed: 2707 additions & 892 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.

backend/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,27 @@ API_BASE_URL=https://api.staging.flowfi.io
2929
We use Prisma as our ORM to interact with PostgreSQL.
3030

3131
- Schema is located at `prisma/schema.prisma`.
32+
- Configuration (schema path, migrations path, datasource URL) is defined in `prisma.config.ts`.
3233
- Run `npx prisma studio` to view the database through a web UI.
3334

35+
### Seeding the database
36+
37+
`prisma/seed.ts` populates the database with demo fixtures for local development. Run it with:
38+
39+
```bash
40+
npm run prisma:seed
41+
```
42+
43+
(this runs `prisma db seed`, which in turn runs `tsx prisma/seed.ts` as configured under the `prisma.seed` key in `package.json`.)
44+
45+
The script is idempotent (it uses `upsert`/fixed IDs), so it's safe to run multiple times. It creates:
46+
47+
- Two demo users, keyed by fixed Stellar testnet public keys — a sender (`GCM5WPR4DDR24FSAX5LIEM4J7AI3KOWJYANSXEPKYXCSZOTAYXE75AFN`) and a recipient (`GBJCHUKZMTFSLOMNC7P4TS4VJJBTCYL3XKSOLXAUJSD56C4LHND5TWUC`).
48+
- One demo `Stream` (`streamId: 101`) between those two users, using a fixed demo token address, with a sample rate/deposit amount and `isActive: true`.
49+
- One demo `StreamEvent` (`eventType: 'CREATED'`) attached to that stream, with sample transaction hash, ledger sequence, and metadata.
50+
51+
These fixtures are intended purely for local development/demo purposes so the frontend has data to render out of the box; they are not used in automated tests.
52+
3453
## /v1 API
3554

3655
All REST API endpoints are prefixed with `/v1`. Refer to the API Documentation in the root `README.md` and the `docs/` folder for versioning and authentication details.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- Replace the unused (streamId, createdAt) composite with (streamId, timestamp),
2+
-- which matches streamId-scoped event listings that ORDER BY timestamp.
3+
4+
-- CreateIndex
5+
CREATE INDEX IF NOT EXISTS "StreamEvent_streamId_timestamp_idx" ON "StreamEvent"("streamId", "timestamp");
6+
7+
-- DropIndex
8+
DROP INDEX IF EXISTS "StreamEvent_streamId_createdAt_idx";
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- Convert on-chain stream identifiers from int4 to bigint (Soroban u64).
2+
-- Drop the FK first so both columns can be widened, then recreate it.
3+
4+
ALTER TABLE "StreamEvent" DROP CONSTRAINT IF EXISTS "StreamEvent_streamId_fkey";
5+
6+
ALTER TABLE "Stream" ALTER COLUMN "streamId" TYPE BIGINT USING ("streamId"::bigint);
7+
ALTER TABLE "StreamEvent" ALTER COLUMN "streamId" TYPE BIGINT USING ("streamId"::bigint);
8+
9+
ALTER TABLE "StreamEvent"
10+
ADD CONSTRAINT "StreamEvent_streamId_fkey"
11+
FOREIGN KEY ("streamId") REFERENCES "Stream"("streamId")
12+
ON DELETE RESTRICT ON UPDATE CASCADE;

backend/prisma/schema.prisma

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ model User {
2727
// Stream model - mirrors on-chain stream state for fast querying
2828
model Stream {
2929
id String @id @default(uuid())
30-
streamId Int @unique // On-chain stream ID
30+
streamId BigInt @unique // On-chain stream ID (Soroban u64)
3131
sender String // Sender's Stellar public key
3232
recipient String // Recipient's Stellar public key
3333
tokenAddress String // Token contract address
@@ -68,7 +68,7 @@ model IndexerState {
6868
// StreamEvent model - indexer events for tracking all on-chain stream activities
6969
model StreamEvent {
7070
id String @id @default(uuid())
71-
streamId Int // Reference to on-chain stream ID
71+
streamId BigInt // Reference to on-chain stream ID (Soroban u64)
7272
eventType String // EventType: "CREATED", "TOPPED_UP", "WITHDRAWN", "CANCELLED", "COMPLETED", "PAUSED", "RESUMED"
7373
amount String? // Amount involved in the event (for top-ups, withdrawals)
7474
transactionHash String // Stellar transaction hash
@@ -86,5 +86,6 @@ model StreamEvent {
8686
@@index([timestamp])
8787
@@index([transactionHash])
8888
@@index([createdAt])
89-
@@index([streamId, createdAt])
89+
@@index([streamId, timestamp])
90+
@@unique([transactionHash, eventType])
9091
}

backend/src/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { requestIdMiddleware } from "./middleware/requestId.js";
1616
import v1Routes from "./routes/v1/index.js";
1717

1818
import healthRoutes from "./routes/health.routes.js";
19+
import "./lib/stream-id.js";
1920

2021
const app = express();
2122
const isProduction = process.env.NODE_ENV === "production";

backend/src/controllers/sse.controller.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ export const subscribe = async (req: Request, res: Response) => {
3131

3232
try {
3333
const sourceIp = getClientIp(req);
34-
const capacity = sseService.checkCapacity(sourceIp);
34+
const authUserId = (req as AuthenticatedRequest).user?.publicKey;
35+
const capacity = sseService.checkCapacity(sourceIp, authUserId);
3536
if (!capacity.allowed) {
3637
if (capacity.retryAfterSeconds) {
3738
res.setHeader('Retry-After', String(capacity.retryAfterSeconds));
@@ -51,7 +52,7 @@ export const subscribe = async (req: Request, res: Response) => {
5152
where: { OR: [{ sender: publicKey }, { recipient: publicKey }] },
5253
select: { streamId: true, sender: true, recipient: true },
5354
});
54-
const ownedIds = new Set(ownedStreams.map((s: { streamId: number }) => String(s.streamId)));
55+
const ownedIds = new Set(ownedStreams.map((s: { streamId: bigint }) => String(s.streamId)));
5556
const allowedUserKeys = new Set<string>([publicKey]);
5657
for (const stream of ownedStreams) {
5758
allowedUserKeys.add(stream.sender);
@@ -87,7 +88,7 @@ export const subscribe = async (req: Request, res: Response) => {
8788
const requestId = requestContext.getStore()?.requestId;
8889
res.write(`data: ${JSON.stringify({ type: 'connected', clientId, requestId })}\n\n`);
8990

90-
sseService.addClient(clientId, res, subscriptions, sourceIp);
91+
sseService.addClient(clientId, res, subscriptions, sourceIp, publicKey);
9192
return;
9293
} catch (error: unknown) {
9394
if (error instanceof z.ZodError) {

backend/src/controllers/stream.controller.ts

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
resumeStream as sorobanResumeStream,
1414
} from "../services/sorobanService.js";
1515
import type { AuthenticatedRequest } from "../types/auth.types.js";
16+
import { parseStreamId } from "../lib/stream-id.js";
1617
import {
1718
DEFAULT_EVENTS_PAGE_SIZE,
1819
MAX_EVENTS_PAGE_SIZE,
@@ -128,10 +129,10 @@ export const createStream = async (req: Request, res: Response) => {
128129
});
129130
}
130131

131-
const parsedStreamId = Number.parseInt(streamId, 10);
132+
const parsedStreamId = parseStreamId(streamId);
132133
const parsedStartTime = Number.parseInt(startTime, 10);
133134

134-
if (!Number.isFinite(parsedStreamId)) {
135+
if (parsedStreamId === null) {
135136
return res
136137
.status(400)
137138
.json({ error: "Invalid streamId: must be a valid integer" });
@@ -347,9 +348,8 @@ export const getStream = async (req: Request, res: Response) => {
347348
const streamIdParam = Array.isArray(req.params.streamId)
348349
? req.params.streamId[0]
349350
: req.params.streamId;
350-
const parsedStreamId = Number.parseInt(streamIdParam ?? "", 10);
351-
352-
if (!Number.isFinite(parsedStreamId)) {
351+
const parsedStreamId = parseStreamId(streamIdParam);
352+
if (parsedStreamId === null) {
353353
return res.status(400).json({ error: "Invalid streamId parameter" });
354354
}
355355

@@ -398,9 +398,8 @@ export const getStreamEvents = async (req: Request, res: Response) => {
398398
const streamIdParam = Array.isArray(req.params.streamId)
399399
? req.params.streamId[0]
400400
: req.params.streamId;
401-
const parsedStreamId = Number.parseInt(streamIdParam ?? "", 10);
402-
403-
if (!Number.isFinite(parsedStreamId)) {
401+
const parsedStreamId = parseStreamId(streamIdParam);
402+
if (parsedStreamId === null) {
404403
return res.status(400).json({ error: "Invalid streamId parameter" });
405404
}
406405

@@ -489,9 +488,8 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => {
489488
const streamIdParam = Array.isArray(req.params.streamId)
490489
? req.params.streamId[0]
491490
: req.params.streamId;
492-
const parsedStreamId = Number.parseInt(streamIdParam ?? "", 10);
493-
494-
if (!Number.isFinite(parsedStreamId)) {
491+
const parsedStreamId = parseStreamId(streamIdParam);
492+
if (parsedStreamId === null) {
495493
return res.status(400).json({ error: "Invalid streamId parameter" });
496494
}
497495

@@ -686,13 +684,12 @@ const topUpBodySchema = z.object({
686684
* Adds tokens to a running stream. Only the stream sender may call this.
687685
*/
688686
export const topUpStreamHandler = async (req: Request, res: Response) => {
689-
const streamId = parseInt(
687+
const streamId = parseStreamId(
690688
Array.isArray(req.params.streamId)
691-
? req.params.streamId[0]!
692-
: (req.params.streamId ?? ""),
693-
10,
689+
? req.params.streamId[0]
690+
: req.params.streamId,
694691
);
695-
if (isNaN(streamId)) {
692+
if (streamId === null) {
696693
return res.status(400).json({ error: "Invalid streamId" });
697694
}
698695

@@ -769,9 +766,8 @@ export const pauseStream = async (req: Request, res: Response) => {
769766
const streamIdParam = Array.isArray(req.params.streamId)
770767
? req.params.streamId[0]
771768
: req.params.streamId;
772-
const parsedStreamId = Number.parseInt(streamIdParam ?? "", 10);
773-
774-
if (!Number.isFinite(parsedStreamId)) {
769+
const parsedStreamId = parseStreamId(streamIdParam);
770+
if (parsedStreamId === null) {
775771
return res.status(400).json({ error: "Invalid streamId parameter" });
776772
}
777773

@@ -861,9 +857,8 @@ export const resumeStream = async (req: Request, res: Response) => {
861857
const streamIdParam = Array.isArray(req.params.streamId)
862858
? req.params.streamId[0]
863859
: req.params.streamId;
864-
const parsedStreamId = Number.parseInt(streamIdParam ?? "", 10);
865-
866-
if (!Number.isFinite(parsedStreamId)) {
860+
const parsedStreamId = parseStreamId(streamIdParam);
861+
if (parsedStreamId === null) {
867862
return res.status(400).json({ error: "Invalid streamId parameter" });
868863
}
869864

backend/src/controllers/stream/cancel.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import logger from '../../logger.js';
44
import * as sorobanService from '../../services/sorobanService.js';
55
import type { AuthenticatedRequest } from '../../types/auth.types.js';
66
import * as streamRepository from '../../repositories/stream.repository.js';
7+
import { parseStreamId } from '../../lib/stream-id.js';
78

89
/**
910
* @openapi
@@ -55,7 +56,10 @@ export const cancelStreamHandler = async (req: AuthenticatedRequest, res: Respon
5556
return res.status(400).json({ error: 'Missing streamId parameter' });
5657
}
5758

58-
const parsedStreamId = parseInt(streamId, 10);
59+
const parsedStreamId = parseStreamId(streamId);
60+
if (parsedStreamId === null) {
61+
return res.status(400).json({ error: 'Invalid streamId parameter' });
62+
}
5963

6064
// 1. Fetch stream from DB
6165
const stream = await prisma.stream.findUnique({

backend/src/controllers/user.controller.ts

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,54 @@ import { registerUserSchema } from '../validators/user.validator.js';
55
import type { AuthenticatedRequest } from '../types/auth.types.js';
66
import { DEFAULT_EVENTS_PAGE_SIZE, MAX_EVENTS_PAGE_SIZE } from '../routes/v1/events.routes.js';
77

8+
/**
9+
* Public shape of a Stream, used when embedding streams inside a public
10+
* user response. Excludes nothing sensitive today, but is kept explicit
11+
* so newly added internal-only fields on the Stream model are not
12+
* leaked automatically.
13+
*/
14+
const publicStreamSelect = {
15+
id: true,
16+
streamId: true,
17+
sender: true,
18+
recipient: true,
19+
tokenAddress: true,
20+
ratePerSecond: true,
21+
depositedAmount: true,
22+
withdrawnAmount: true,
23+
startTime: true,
24+
lastUpdateTime: true,
25+
endTime: true,
26+
isActive: true,
27+
isPaused: true,
28+
pausedAt: true,
29+
totalPausedDuration: true,
30+
createdAt: true,
31+
updatedAt: true,
32+
} as const;
33+
34+
/**
35+
* Public shape of a User. Limits the response to fields that are safe to
36+
* expose to any caller, so internal-only fields added to the User model
37+
* later are excluded by default rather than leaked automatically.
38+
*/
39+
const publicUserSelect = {
40+
id: true,
41+
publicKey: true,
42+
createdAt: true,
43+
updatedAt: true,
44+
sentStreams: {
45+
take: 10,
46+
orderBy: { createdAt: 'desc' as const },
47+
select: publicStreamSelect,
48+
},
49+
receivedStreams: {
50+
take: 10,
51+
orderBy: { createdAt: 'desc' as const },
52+
select: publicStreamSelect,
53+
},
54+
};
55+
856
/**
957
* Register a new wallet public key
1058
*/
@@ -49,16 +97,7 @@ export const getUser = async (req: Request, res: Response, next: NextFunction) =
4997

5098
const user = await prisma.user.findUnique({
5199
where: { publicKey },
52-
include: {
53-
sentStreams: {
54-
take: 10,
55-
orderBy: { createdAt: 'desc' }
56-
},
57-
receivedStreams: {
58-
take: 10,
59-
orderBy: { createdAt: 'desc' }
60-
}
61-
}
100+
select: publicUserSelect
62101
});
63102

64103
if (!user) {

backend/src/lib/indexer-state.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,59 @@
1+
import { prisma } from './prisma.js';
2+
import logger from '../logger.js';
3+
14
export const INDEXER_STATE_ID = 'singleton';
5+
6+
export interface IndexerStateRow {
7+
id: string;
8+
lastLedger: number;
9+
lastCursor: string | null;
10+
createdAt: Date;
11+
updatedAt: Date;
12+
}
13+
14+
/**
15+
* Ensure the singleton indexer_state row exists.
16+
* Uses a catch-and-retry pattern to handle the race condition where two
17+
* concurrent callers attempt the first insert simultaneously. If the
18+
* unique-constraint violation fires, we treat it as success and re-read.
19+
*/
20+
export async function ensureIndexerState(
21+
startLedger: number,
22+
): Promise<IndexerStateRow> {
23+
const existing = await prisma.indexerState.findUnique({
24+
where: { id: INDEXER_STATE_ID },
25+
});
26+
if (existing) return existing;
27+
28+
try {
29+
const created = await prisma.indexerState.create({
30+
data: {
31+
id: INDEXER_STATE_ID,
32+
lastLedger: startLedger,
33+
lastCursor: null,
34+
},
35+
});
36+
return created;
37+
} catch (err: unknown) {
38+
// P2002 = Prisma unique-constraint violation (code "P2002")
39+
if (
40+
err instanceof Error &&
41+
'code' in err &&
42+
(err as { code: string }).code === 'P2002'
43+
) {
44+
logger.warn(
45+
'[IndexerState] Concurrent first-insert detected; re-reading existing row.',
46+
);
47+
const existingAfterRace = await prisma.indexerState.findUnique({
48+
where: { id: INDEXER_STATE_ID },
49+
});
50+
if (!existingAfterRace) {
51+
throw new Error(
52+
'[IndexerState] Unique-constraint violation but row not found after race.',
53+
);
54+
}
55+
return existingAfterRace;
56+
}
57+
throw err;
58+
}
59+
}

0 commit comments

Comments
 (0)