Skip to content

Commit b8185bb

Browse files
authored
Merge pull request #1405 from samjay8/fix/audit-1253-1269-1273-1286
fix(audit): address 4 second-wave audit issues (#1253, #1269, #1273, #1286)
2 parents 2ef9e50 + 32767e8 commit b8185bb

11 files changed

Lines changed: 532 additions & 400 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ jobs:
6464
run: npm run build
6565
working-directory: frontend
6666

67+
- name: Check frontend bundle size
68+
run: bash ./scripts/check-bundle-size.sh
69+
working-directory: frontend
70+
6771
backend:
6872
name: Backend CI
6973
runs-on: ubuntu-latest

backend/src/controllers/stream.controller.ts

Lines changed: 13 additions & 77 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
*/
@@ -116,19 +86,18 @@ export const createStream = async (req: Request, res: Response) => {
11686
return res.status(401).json({ error: 'Unauthorized', message: 'Authentication required' });
11787
}
11888

119-
const { streamId, sender, recipient, tokenAddress, ratePerSecond, depositedAmount, startTime } = req.body;
120-
121-
// Issue #809: validate identity fields before any DB write.
122-
if (typeof sender !== 'string' || sender.length === 0) {
123-
return res.status(400).json({ error: 'Invalid sender: must be a non-empty string' });
124-
}
125-
if (typeof recipient !== 'string' || recipient.length === 0) {
126-
return res.status(400).json({ error: 'Invalid recipient: must be a non-empty string' });
127-
}
128-
if (typeof tokenAddress !== 'string' || tokenAddress.length === 0) {
129-
return res.status(400).json({ error: 'Invalid tokenAddress: must be a non-empty string' });
89+
// Validate request body using the Zod schema, which includes the MAX_I128
90+
// upper-bound check on ratePerSecond that the manual parsing omitted.
91+
const parsed = createStreamSchema.safeParse(req.body);
92+
if (!parsed.success) {
93+
return res.status(400).json({
94+
error: 'Validation error',
95+
details: parsed.error.issues,
96+
});
13097
}
13198

99+
const { streamId: parsedStreamId, sender, recipient, tokenAddress, ratePerSecond, depositedAmount, startTime: parsedStartTime } = parsed.data;
100+
132101
// Issue #809: the authenticated wallet may only create/modify streams it owns.
133102
// Without this, any logged-in wallet could POST an arbitrary `sender` and have
134103
// it persisted, or flip another owner's cancelled stream back to active.
@@ -139,41 +108,8 @@ export const createStream = async (req: Request, res: Response) => {
139108
});
140109
}
141110

142-
const parsedStreamId = parseStreamId(streamId);
143-
const parsedStartTime = Number.parseInt(startTime, 10);
144-
145-
if (parsedStreamId === null) {
146-
return res
147-
.status(400)
148-
.json({ error: "Invalid streamId: must be a valid integer" });
149-
}
150-
151-
if (!Number.isFinite(parsedStartTime) || parsedStartTime < 0) {
152-
return res
153-
.status(400)
154-
.json({ error: "Invalid startTime: must be a non-negative integer" });
155-
}
156-
157-
// Presence/format validation happens here, before any BigInt coercion,
158-
// so a malformed or missing numeric field always yields 400 rather than
159-
// an uncaught SyntaxError/TypeError falling through to 500.
160-
let parsedRatePerSecond: bigint;
161-
let parsedDepositedAmount: bigint;
162-
try {
163-
parsedRatePerSecond = parseRequiredBigIntField(
164-
"ratePerSecond",
165-
ratePerSecond,
166-
);
167-
parsedDepositedAmount = parseRequiredBigIntField(
168-
"depositedAmount",
169-
depositedAmount,
170-
);
171-
} catch (validationError) {
172-
if (validationError instanceof StreamValidationError) {
173-
return res.status(400).json({ error: validationError.message });
174-
}
175-
throw validationError;
176-
}
111+
const parsedRatePerSecond = BigInt(ratePerSecond);
112+
const parsedDepositedAmount = BigInt(depositedAmount);
177113

178114
if (parsedRatePerSecond <= 0n) {
179115
return res

backend/src/services/soroban-indexer.service.ts

Lines changed: 0 additions & 60 deletions
This file was deleted.

backend/tests/soroban-indexer.test.ts

Lines changed: 0 additions & 82 deletions
This file was deleted.

backend/tests/stream.controller.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ describe("Stream Controller", () => {
157157
expect(res.status).toHaveBeenCalledWith(400);
158158
expect(res.status).not.toHaveBeenCalledWith(500);
159159
expect(res.json).toHaveBeenCalledWith(
160-
expect.objectContaining({ error: expect.stringContaining('ratePerSecond') })
160+
expect.objectContaining({ error: 'Validation error' })
161161
);
162162
});
163163

@@ -167,7 +167,7 @@ describe("Stream Controller", () => {
167167
expect(res.status).toHaveBeenCalledWith(400);
168168
expect(res.status).not.toHaveBeenCalledWith(500);
169169
expect(res.json).toHaveBeenCalledWith(
170-
expect.objectContaining({ error: expect.stringContaining('depositedAmount') })
170+
expect.objectContaining({ error: 'Validation error' })
171171
);
172172
});
173173

@@ -177,7 +177,7 @@ describe("Stream Controller", () => {
177177
expect(res.status).toHaveBeenCalledWith(400);
178178
expect(res.status).not.toHaveBeenCalledWith(500);
179179
expect(res.json).toHaveBeenCalledWith(
180-
expect.objectContaining({ error: expect.stringContaining('ratePerSecond') })
180+
expect.objectContaining({ error: 'Validation error' })
181181
);
182182
});
183183

@@ -187,7 +187,7 @@ describe("Stream Controller", () => {
187187
expect(res.status).toHaveBeenCalledWith(400);
188188
expect(res.status).not.toHaveBeenCalledWith(500);
189189
expect(res.json).toHaveBeenCalledWith(
190-
expect.objectContaining({ error: expect.stringContaining('depositedAmount') })
190+
expect.objectContaining({ error: 'Validation error' })
191191
);
192192
});
193193
});

docs/ARCHITECTURE.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,21 +205,19 @@ Dashboard / NotificationDropdown re-render with live data
205205

206206
### Indexer Ownership & Naming
207207

208-
Three files with overlapping names live next to each other, but only one of them is the indexer that writes stream state. This section documents which is the source of truth and which is legacy so contributors know where to start when debugging indexing.
208+
Two files share the `indexer` name, but only one of them is the indexer that writes stream state. This section documents which is the source of truth so contributors know where to start when debugging indexing.
209209

210210
| File | Role | Status |
211211
|------|------|--------|
212212
| `backend/src/workers/soroban-event-worker.ts` (`SorobanEventWorker`) | **Source-of-truth indexer.** Polls Soroban RPC, decodes XDR, persists `Stream` / `StreamEvent`, advances the `IndexerState` cursor, and broadcasts SSE. | Active / source of truth. Started by `backend/src/workers/index.ts` |
213-
| `backend/src/services/soroban-indexer.service.ts` (`SorobanIndexerService`) | **Legacy indexer being phased out.** A simpler duplicate poller that writes to the same rows and races with the worker. | **Legacy — do not extend.** Removal tracked with the functional consolidation (issue #801). Started directly from `backend/src/index.ts` |
214-
| `backend/src/services/indexerService.ts` | **Not an indexer at all.** Admin control-plane helpers (`getIndexerStatus`, `resetIndexer`, `replayFromLedger`) that read/reset `IndexerState` and trigger the worker's poll loop. | Active. The name is misleading; it was kept alongside the legacy indexer above |
213+
| `backend/src/services/indexerService.ts` | **Not an indexer at all.** Admin control-plane helpers (`getIndexerStatus`, `resetIndexer`, `replayFromLedger`) that read/reset `IndexerState` and trigger the worker's poll loop. | Active. The name is misleading. |
215214

216215
Key points:
217216

218217
1. **When debugging indexing, read `backend/src/workers/soroban-event-worker.ts` first.** It is the only file that persists canonical stream state.
219-
2. **Do not add new behavior to `soroban-indexer.service.ts`.** It exists only for backwards compatibility while the double-indexer race (issue #801) is consolidated.
220-
3. **`indexerService.ts` is control-plane only** — it never reads the chain; it manages the shared cursor and triggers replays.
218+
2. **`indexerService.ts` is control-plane only** — it never reads the chain; it manages the shared cursor and triggers replays.
221219

222-
**Naming convention plan:** the team convention is kebab-case with a `.service.ts` suffix (e.g. `soroban-indexer.service.ts`, `claimable.service.ts`, `sse.service.ts`). The helper file `indexerService.ts` breaks that convention and is also a misleading name. Once the functional consolidation (issue #801) lands, `indexerService.ts` is expected to be renamed to `indexer.service.ts`.
220+
**Naming convention plan:** the team convention is kebab-case with a `.service.ts` suffix (e.g. `claimable.service.ts`, `sse.service.ts`). The helper file `indexerService.ts` breaks that convention and is also a misleading name. It is expected to be renamed to `indexer.service.ts`.
223221

224222
### Deduplication
225223

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env bash
2+
# Bundle size budget check for the Next.js frontend build.
3+
# Compares the total size of static JS files in .next/static against a
4+
# configurable budget (default 300 KB gzipped). Fails the CI step when
5+
# the budget is exceeded.
6+
set -euo pipefail
7+
8+
BUDGET_BYTES=${FRONTEND_BUNDLE_BUDGET_BYTES:-614400} # 600 KB
9+
NEXT_STATIC_DIR=".next/static"
10+
11+
if [ ! -d "$NEXT_STATIC_DIR" ]; then
12+
echo "Error: $NEXT_STATIC_DIR directory not found. Run 'next build' first."
13+
exit 1
14+
fi
15+
16+
total=0
17+
for f in $(find "$NEXT_STATIC_DIR" -type f -name "*.js" | head -100); do
18+
# Use gzip -c | wc -c for accurate gzipped size
19+
gzipped_size=$(gzip -c "$f" | wc -c)
20+
total=$((total + gzipped_size))
21+
done
22+
23+
echo "Frontend JS bundle gzipped size: ${total} bytes (${BUDGET_BYTES} byte budget)"
24+
25+
if [ "$total" -gt "$BUDGET_BYTES" ]; then
26+
echo "Error: Frontend bundle exceeds size budget!"
27+
echo " Actual: ${total} bytes"
28+
echo " Budget: ${BUDGET_BYTES} bytes"
29+
echo " Overage: $((total - BUDGET_BYTES)) bytes"
30+
exit 1
31+
fi
32+
33+
echo "Bundle size OK ✓"

0 commit comments

Comments
 (0)