Skip to content

Commit 3330dd2

Browse files
Merge branch 'main' into #1232
2 parents 805664b + c26d697 commit 3330dd2

46 files changed

Lines changed: 2686 additions & 1136 deletions

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: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,6 @@ jobs:
5252
run: npm run lint
5353
working-directory: frontend
5454

55-
- name: Install Rollup Native Binding
56-
run: npm install @rollup/rollup-linux-x64-gnu --no-save
57-
working-directory: frontend
58-
5955
- name: Run Frontend Tests
6056
run: npm run test:coverage
6157
working-directory: frontend
@@ -64,6 +60,10 @@ jobs:
6460
run: npm run build
6561
working-directory: frontend
6662

63+
- name: Check frontend bundle size
64+
run: bash ./scripts/check-bundle-size.sh
65+
working-directory: frontend
66+
6767
backend:
6868
name: Backend CI
6969
runs-on: ubuntu-latest
@@ -121,9 +121,6 @@ jobs:
121121
cd ..
122122
git diff --exit-code -- backend/swagger/flowfi.openapi.json frontend/src/lib/api-types.generated.ts
123123
124-
- name: Install Rollup Native Binding
125-
run: npm install @rollup/rollup-linux-x64-gnu --no-save
126-
127124
- name: Run Backend Tests
128125
run: |
129126
ls -la src/generated/prisma

.github/workflows/pr-test-gate.yml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,6 @@ jobs:
6363
env:
6464
DATABASE_URL: postgresql://postgres:password@127.0.0.1:5432/flowfi_test
6565

66-
- name: Install Native Bindings
67-
run: |
68-
npm install @rollup/rollup-linux-x64-gnu --no-save
69-
working-directory: backend
70-
7166
- name: Run backend tests
7267
run: |
7368
ls -la src/generated/prisma

backend/docs/SSE_ARCHITECTURE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,9 @@ The indexer worker behavior is controlled by environment variables configured in
294294
| `SOROBAN_RPC_URL` | Endpoint URL for the Soroban RPC node. | `"https://soroban-testnet.stellar.org"` | Uses default public testnet RPC URL. |
295295
| `INDEXER_POLL_INTERVAL_MS` | Polling interval in milliseconds between event fetch cycles. | `"5000"` (5 seconds) | Uses default 5000 ms interval. |
296296
| `INDEXER_START_LEDGER` | Starting Stellar ledger sequence number for cold starts when no `IndexerState` record exists in the database. | `"0"` | Starts indexing from ledger 0 on initial setup. |
297+
| `INDEXER_DEAD_LETTER_MAX_RETRIES` | Max failed processing attempts before an event is abandoned to the `IndexerDeadLetterEvent` table and the cursor advances past it. | `"5"` | Uses default of 5 attempts. |
298+
299+
Failed events never freeze the indexer: the cursor always advances past successfully processed events even when an earlier event in the batch failed, and each failing event is recorded (with its raw payload) in the `IndexerDeadLetterEvent` table for manual triage. After `INDEXER_DEAD_LETTER_MAX_RETRIES` attempts the event is abandoned and the cursor advances past it.
297300

298301
---
299302

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
-- Dead-letter table for Soroban events that failed to process. A single
2+
-- malformed event must not freeze the indexer: after N failed attempts the
3+
-- worker abandons the event (recording it here with its raw payload for
4+
-- manual triage) and advances the cursor past it.
5+
6+
-- CreateTable
7+
CREATE TABLE "IndexerDeadLetterEvent" (
8+
"id" TEXT NOT NULL,
9+
"eventId" TEXT NOT NULL,
10+
"ledger" INTEGER NOT NULL,
11+
"transactionHash" TEXT NOT NULL,
12+
"rawPayload" TEXT NOT NULL,
13+
"errorMessage" TEXT NOT NULL,
14+
"attempts" INTEGER NOT NULL DEFAULT 1,
15+
"lastAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
16+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
17+
18+
CONSTRAINT "IndexerDeadLetterEvent_pkey" PRIMARY KEY ("id")
19+
);
20+
21+
-- CreateIndex
22+
CREATE UNIQUE INDEX "IndexerDeadLetterEvent_eventId_key" ON "IndexerDeadLetterEvent"("eventId");
23+
24+
-- CreateIndex
25+
CREATE INDEX "IndexerDeadLetterEvent_ledger_idx" ON "IndexerDeadLetterEvent"("ledger");
26+
27+
-- CreateIndex
28+
CREATE INDEX "IndexerDeadLetterEvent_transactionHash_idx" ON "IndexerDeadLetterEvent"("transactionHash");
29+
30+
-- CreateIndex
31+
CREATE INDEX "IndexerDeadLetterEvent_createdAt_idx" ON "IndexerDeadLetterEvent"("createdAt");

backend/prisma/schema.prisma

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,26 @@ model IndexerState {
6464
updatedAt DateTime @updatedAt
6565
}
6666

67+
// IndexerDeadLetterEvent model - Soroban events that failed to process, kept
68+
// with their raw payload for manual triage. The worker retries an event at
69+
// most INDEXER_DEAD_LETTER_MAX_RETRIES times, then abandons it and advances
70+
// the cursor so a single malformed event can never freeze the indexer.
71+
model IndexerDeadLetterEvent {
72+
id String @id @default(uuid())
73+
eventId String @unique // RPC paging-token id of the event
74+
ledger Int // Ledger sequence the event was emitted in
75+
transactionHash String // Stellar transaction hash
76+
rawPayload String // Full raw event JSON for manual triage/replay
77+
errorMessage String // Last error thrown while processing
78+
attempts Int @default(1) // Number of failed processing attempts
79+
lastAttemptAt DateTime @default(now())
80+
createdAt DateTime @default(now())
81+
82+
@@index([ledger])
83+
@@index([transactionHash])
84+
@@index([createdAt])
85+
}
86+
6787
// StreamEvent model - indexer events for tracking all on-chain stream activities
6888
model StreamEvent {
6989
id String @id @default(uuid())

backend/src/controllers/stream.controller.ts

Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "../services/sorobanService.js";
1515
import type { AuthenticatedRequest } from "../types/auth.types.js";
1616
import { parseStreamId } from "../lib/stream-id.js";
17+
import { createStreamSchema } from "../validators/stream.validator.js";
1718
import {
1819
DEFAULT_EVENTS_PAGE_SIZE,
1920
MAX_EVENTS_PAGE_SIZE,
@@ -75,37 +76,6 @@ function sumStringI128(values: string[]): string {
7576
return total.toString();
7677
}
7778

78-
/**
79-
* Thrown when a request body field fails presence/format validation. Kept
80-
* distinct from generic errors so createStream can reliably map it to a 400
81-
* response instead of falling through to the catch-all 500.
82-
*/
83-
class StreamValidationError extends Error {
84-
constructor(message: string) {
85-
super(message);
86-
this.name = "StreamValidationError";
87-
}
88-
}
89-
90-
/**
91-
* Validate presence and integer format of a required i128-style field, then
92-
* coerce it to a BigInt. Any missing value or conversion failure (SyntaxError
93-
* from a non-numeric string, TypeError from undefined/null/objects, etc.) is
94-
* normalized into a StreamValidationError so the caller can map it to 400.
95-
*/
96-
function parseRequiredBigIntField(fieldName: string, value: unknown): bigint {
97-
if (value === undefined || value === null || value === "") {
98-
throw new StreamValidationError(`Missing required field: ${fieldName}`);
99-
}
100-
try {
101-
return BigInt(value as bigint | number | string | boolean);
102-
} catch {
103-
throw new StreamValidationError(
104-
`Invalid ${fieldName}: must be a valid integer`,
105-
);
106-
}
107-
}
108-
10979
/**
11080
* Create a new stream (stub for on-chain indexing)
11181
*/
@@ -129,6 +99,8 @@ export const createStream = async (req: Request, res: Response) => {
12999
return sendApiError(res, 400, 'INVALID_TOKEN_ADDRESS', 'Invalid tokenAddress: must be a non-empty string');
130100
}
131101

102+
const { streamId: parsedStreamId, sender, recipient, tokenAddress, ratePerSecond, depositedAmount, startTime: parsedStartTime } = parsed.data;
103+
132104
// Issue #809: the authenticated wallet may only create/modify streams it owns.
133105
// Without this, any logged-in wallet could POST an arbitrary `sender` and have
134106
// it persisted, or flip another owner's cancelled stream back to active.

backend/src/controllers/stream/cancel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export const cancelStreamHandler = async (req: AuthenticatedRequest, res: Respon
8888
return sendApiError(res, 500, 'INTERNAL_SERVER_ERROR', 'Backend not configured for on-chain calls');
8989
}
9090

91-
const txHash = await sorobanService.cancelStream(parsedStreamId, secretKey);
91+
const txHash = await sorobanService.cancelStream(parsedStreamId, senderSecret);
9292

9393
// 5. Update DB record status using repository helper
9494
await streamRepository.updateStatus(parsedStreamId, 'CANCELLED');

backend/src/controllers/user.controller.ts

Lines changed: 43 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import {
77
} from "../validators/user.validator.js";
88
import type { AuthenticatedRequest } from "../types/auth.types.js";
99
import {
10-
DEFAULT_EVENTS_PAGE_SIZE,
11-
MAX_EVENTS_PAGE_SIZE,
12-
} from "../routes/v1/events.routes.js";
10+
listEventsForWallet,
11+
parseEventTypeFilter,
12+
resolveEventsOffset,
13+
resolveEventsPageSize,
14+
} from "../repositories/streamEvent.repository.js";
1315
import * as exportService from "../services/export.service.js";
1416
import { sendApiError } from "../types/api-error.js";
1517

@@ -127,7 +129,17 @@ export const getUser = async (
127129
};
128130

129131
/**
130-
* Get user events (history)
132+
* Get user events (history) - paginated list of stream events where the
133+
* given wallet was either the sender or recipient.
134+
*
135+
* Query params:
136+
* - type: optional comma-separated list of event types to filter by
137+
* (e.g. "PAUSED,RESUMED"); unknown values are ignored, and a filter
138+
* consisting entirely of unknown values is rejected with 400.
139+
* - limit, offset, page: pagination (see repositories/streamEvent.repository.ts)
140+
* - includeStream: set to "false" to omit the related `stream` object
141+
* from each event (included by default, matching this endpoint's
142+
* historical behavior).
131143
*/
132144
export const getUserEvents = async (
133145
req: Request,
@@ -143,47 +155,38 @@ export const getUserEvents = async (
143155
return sendApiError(res, 400, "INVALID_PUBLIC_KEY", "Invalid Stellar public key format");
144156
}
145157

146-
const rawLimit = req.query["limit"];
147-
const rawOffset = req.query["offset"];
148-
149-
const limit = Math.min(
150-
rawLimit && typeof rawLimit === "string"
151-
? Number.parseInt(rawLimit, 10) || DEFAULT_EVENTS_PAGE_SIZE
152-
: DEFAULT_EVENTS_PAGE_SIZE,
153-
MAX_EVENTS_PAGE_SIZE,
154-
);
155-
const offset =
156-
rawOffset && typeof rawOffset === "string"
157-
? Math.max(0, Number.parseInt(rawOffset, 10) || 0)
158-
: 0;
159-
160-
const whereClause = {
161-
stream: {
162-
OR: [{ sender: publicKey }, { recipient: publicKey }],
163-
},
164-
};
158+
const { requested, types } = parseEventTypeFilter(req.query["type"]);
159+
if (requested.length > 0 && types.length === 0) {
160+
return res
161+
.status(400)
162+
.json({ error: "No valid event types in `type` filter" });
163+
}
165164

166-
const [events, total] = await Promise.all([
167-
prisma.streamEvent.findMany({
168-
where: whereClause,
169-
orderBy: { timestamp: "desc" },
170-
take: limit,
171-
skip: offset,
172-
include: {
173-
stream: true,
174-
},
175-
}),
176-
prisma.streamEvent.count({ where: whereClause }),
177-
]);
165+
const limit = resolveEventsPageSize(req.query["limit"]);
166+
const offset = resolveEventsOffset({
167+
rawOffset: req.query["offset"],
168+
rawPage: req.query["page"],
169+
limit,
170+
});
178171

179-
const hasMore = offset + events.length < total;
172+
// Preserve this endpoint's historical behavior of always embedding the
173+
// related stream, unless the caller opts out.
174+
const includeStream = req.query["includeStream"] !== "false";
180175

181-
return res.status(200).json({
182-
data: events,
183-
total,
184-
hasMore,
176+
const result = await listEventsForWallet({
177+
address: publicKey,
178+
types,
185179
limit,
186180
offset,
181+
includeStream,
182+
});
183+
184+
return res.status(200).json({
185+
data: result.events,
186+
total: result.total,
187+
hasMore: result.hasMore,
188+
limit: result.limit,
189+
offset: result.offset,
187190
});
188191
} catch (error) {
189192
return next(error);

0 commit comments

Comments
 (0)