Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -87,5 +87,4 @@ model StreamEvent {
@@index([transactionHash])
@@index([createdAt])
@@index([streamId, timestamp])
@@unique([transactionHash, eventType])
}
1 change: 0 additions & 1 deletion backend/src/lib/indexer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ export interface IndexerStateRow {
id: string;
lastLedger: number;
lastCursor: string | null;
createdAt: Date;
updatedAt: Date;
}

Expand Down
2 changes: 1 addition & 1 deletion backend/src/middleware/admin-rate-limiter.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const adminRateLimiter = rateLimit({
// Use x-forwarded-for or remote address as key
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string') {
return forwarded.split(',')[0].trim();
return forwarded.split(',')[0]?.trim() ?? 'unknown';
}
return req.ip ?? 'unknown';
},
Expand Down
10 changes: 9 additions & 1 deletion backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ if (isProduction) {

const JWT_EXPIRY_SECONDS = 3600; // 1 hour max per spec

const JWT_ISSUER = process.env.JWT_ISSUER || 'flowfi-api';
const JWT_AUDIENCE = process.env.JWT_AUDIENCE || 'flowfi-api';

const STELLAR_NETWORK =
process.env.STELLAR_NETWORK === 'mainnet'
? StellarSdk.Networks.PUBLIC
Expand Down Expand Up @@ -130,6 +133,11 @@ export function verifyJwt(token: string): { publicKey: string } | null {
return null;
}

// Verify issuer and audience
if (payload.iss !== JWT_ISSUER || payload.aud !== JWT_AUDIENCE) {
return null;
}

return { publicKey: payload.sub };
} catch {
return null;
Expand Down Expand Up @@ -205,7 +213,7 @@ export function verifyChallenge(req: Request, res: Response): void {
challenges.delete(publicKey);

const now = Math.floor(Date.now() / 1000);
const token = signJwt({ sub: publicKey, iat: now, exp: now + JWT_EXPIRY_SECONDS });
const token = signJwt({ sub: publicKey, iat: now, exp: now + JWT_EXPIRY_SECONDS, iss: JWT_ISSUER, aud: JWT_AUDIENCE });
res.json({ token, expiresIn: JWT_EXPIRY_SECONDS });
} catch (err) {
logger.error('[Auth] verifyChallenge error:', err);
Expand Down
4 changes: 3 additions & 1 deletion backend/src/routes/health.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Router, type Request, type Response } from 'express';
import { prisma } from '../lib/prisma.js';
import { INDEXER_STATE_ID } from '../lib/indexer-state.js';
import { sorobanEventWorker } from '../workers/soroban-event-worker.js';
import { isRedisAvailable } from '../lib/redis.js';
import { checkRpcHealth } from '../services/sorobanService.js';

const router = Router();

Expand Down Expand Up @@ -161,7 +163,7 @@ router.get('/', async (_req: Request, res: Response) => {
status: dbStatus === 'connected' ? 'ok' : 'down',
},
indexer: {
status: !indexerEnabled ? 'disabled' : indexerDegraded ? 'degraded' : 'ok',
status: !indexerEnabled ? 'disabled' : indexerLagDegraded ? 'degraded' : 'ok',
enabled: indexerEnabled,
lagSeconds: indexerLag === -1 ? null : indexerLag,
},
Expand Down
3 changes: 1 addition & 2 deletions backend/src/services/soroban-indexer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,7 @@ export class SorobanIndexerService {
const ratePerSecond = this.readString(value, 'rate_per_second', 'ratePerSecond');
const depositedAmount = this.readString(value, 'deposited_amount', 'depositedAmount');
const startTimeRaw = value.start_time ?? value.startTime ?? timestamp;
const startTime = BigInt(startTimeRaw ?? timestamp);
const timestampBigInt = BigInt(timestamp);
const startTime = BigInt(String(startTimeRaw ?? timestamp));

if (!sender || !recipient || !tokenAddress || !ratePerSecond || !depositedAmount) return;

Expand Down
2 changes: 2 additions & 0 deletions backend/src/types/auth.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@ export interface SEP10TokenPayload {
sub: string; // Stellar public key
iat: number; // Issued at
exp: number; // Expiration
iss: string; // Issuer
aud: string; // Audience
}
12 changes: 2 additions & 10 deletions backend/src/workers/soroban-event-worker.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { randomUUID } from "crypto";
import { rpc, xdr, StrKey } from "@stellar/stellar-sdk";
import { prisma } from "../lib/prisma.js";
import { INDEXER_STATE_ID, ensureIndexerState } from "../lib/indexer-state.js";
import { sseService } from "../services/sse.service.js";
import logger, { requestContext } from "../logger.js";
import logger from "../logger.js";
import { Prisma } from "../generated/prisma/index.js";
import "../lib/stream-id.js";

Expand Down Expand Up @@ -35,7 +34,7 @@
* Full value = hi * 2^64 + lo.
*/
export function decodeI128(val: xdr.ScVal): string {
const parts = val.i128();

Check failure on line 37 in backend/src/workers/soroban-event-worker.ts

View workflow job for this annotation

GitHub Actions / Backend npm test

tests/integration/stream-lifecycle.test.ts > Stream Lifecycle Integration Tests > Full lifecycle: create → top up → partial withdraw → cancel > walks a single stream through every phase and verifies indexer state

TypeError: i128 not set ❯ ChildUnion.get ../node_modules/@stellar/js-xdr/lib/webpack:/XDR/src/union.js:27:13 ❯ ChildUnion.get [as i128] ../node_modules/@stellar/js-xdr/lib/webpack:/XDR/src/union.js:171:25 ❯ decodeI128 src/workers/soroban-event-worker.ts:37:21 ❯ SorobanEventWorker.handleStreamCreated src/workers/soroban-event-worker.ts:567:27 ❯ SorobanEventWorker.processEvent src/workers/soroban-event-worker.ts:399:20 ❯ tests/integration/stream-lifecycle.test.ts:635:20
const hi = BigInt.asIntN(64, BigInt(parts.hi().toString()));
const lo = BigInt.asUintN(64, BigInt(parts.lo().toString()));
return ((hi << 64n) | lo).toString();
Expand Down Expand Up @@ -113,13 +112,6 @@
/** Recent attempt outcomes for sliding-window spike detection. */
private recentOutcomes: { ok: boolean; at: number }[] = [];

/**
* Stable id attached to every log line emitted by the background poll
* loop, since these callbacks fire outside of any HTTP request and would
* otherwise have no requestContext (and thus no correlation id) at all.
*/
private readonly workerId = `soroban-worker:${randomUUID()}`;

constructor() {
const rpcUrl =
process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org";
Expand Down Expand Up @@ -1128,7 +1120,7 @@
// Calculate the duration of this pause interval
let additionalPausedDuration = 0;
if (currentStream.pausedAt) {
additionalPausedDuration = timestamp - currentStream.pausedAt;
additionalPausedDuration = timestamp - Number(currentStream.pausedAt);
}

const newTotalPausedDuration =
Expand Down
24 changes: 19 additions & 5 deletions backend/tests/auth-jwt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ describe('JWT helpers', () => {

it('round-trips through verifyJwt', async () => {
const now = Math.floor(Date.now() / 1000);
const token = signJwt({ sub: 'GTESTPUBLICKEY123', iat: now, exp: now + 3600 });
const token = signJwt({ sub: 'GTESTPUBLICKEY123', iat: now, exp: now + 3600, iss: 'flowfi-api', aud: 'flowfi-api' });

expect(verifyJwt(token)).toEqual({ publicKey: 'GTESTPUBLICKEY123' });
});

it('returns null for a tampered header', async () => {
const now = Math.floor(Date.now() / 1000);
const token = signJwt({ sub: 'GTESTPUBLICKEY123', iat: now, exp: now + 3600 });
const token = signJwt({ sub: 'GTESTPUBLICKEY123', iat: now, exp: now + 3600, iss: 'flowfi-api', aud: 'flowfi-api' });
const parts = token.split('.') as [string, string, string];
parts[0] = parts[0].slice(0, -1) + (parts[0].slice(-1) === 'A' ? 'B' : 'A');

Expand All @@ -32,7 +32,7 @@ describe('JWT helpers', () => {

it('returns null for a tampered body', async () => {
const now = Math.floor(Date.now() / 1000);
const token = signJwt({ sub: 'GTESTPUBLICKEY123', iat: now, exp: now + 3600 });
const token = signJwt({ sub: 'GTESTPUBLICKEY123', iat: now, exp: now + 3600, iss: 'flowfi-api', aud: 'flowfi-api' });
const parts = token.split('.') as [string, string, string];
parts[1] = parts[1].slice(0, -1) + (parts[1].slice(-1) === 'A' ? 'B' : 'A');

Expand All @@ -41,7 +41,7 @@ describe('JWT helpers', () => {

it('returns null for a tampered signature', async () => {
const now = Math.floor(Date.now() / 1000);
const token = signJwt({ sub: 'GTESTPUBLICKEY123', iat: now, exp: now + 3600 });
const token = signJwt({ sub: 'GTESTPUBLICKEY123', iat: now, exp: now + 3600, iss: 'flowfi-api', aud: 'flowfi-api' });
const parts = token.split('.') as [string, string, string];
// Replace the signature with invalid data to ensure verification fails
parts[2] = 'invalid-signature-data-1234567890abcdef';
Expand All @@ -51,7 +51,21 @@ describe('JWT helpers', () => {

it('returns null for an expired token', async () => {
const now = Math.floor(Date.now() / 1000);
const token = signJwt({ sub: 'GTESTPUBLICKEY123', iat: now - 3600, exp: now - 1 });
const token = signJwt({ sub: 'GTESTPUBLICKEY123', iat: now - 3600, exp: now - 1, iss: 'flowfi-api', aud: 'flowfi-api' });

expect(verifyJwt(token)).toBeNull();
});

it('returns null for a token with wrong audience', async () => {
const now = Math.floor(Date.now() / 1000);
const token = signJwt({ sub: 'GTESTPUBLICKEY123', iat: now, exp: now + 3600, iss: 'flowfi-api', aud: 'wrong-audience' });

expect(verifyJwt(token)).toBeNull();
});

it('returns null for a token with wrong issuer', async () => {
const now = Math.floor(Date.now() / 1000);
const token = signJwt({ sub: 'GTESTPUBLICKEY123', iat: now, exp: now + 3600, iss: 'wrong-issuer', aud: 'flowfi-api' });

expect(verifyJwt(token)).toBeNull();
});
Expand Down
30 changes: 25 additions & 5 deletions backend/tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,13 @@ describe('Authentication & Middleware Tests', () => {

it('test_admin_middleware_rejects_non_admin_token', async () => {
const nonAdminKeypair = makeKeypair();
const now = Math.floor(Date.now() / 1000);
const token = signJwt({
sub: nonAdminKeypair.publicKey(),
exp: Math.floor(Date.now() / 1000) + 3600,
iat: now,
exp: now + 3600,
iss: 'flowfi-api',
aud: 'flowfi-api',
});

// Set admin key to something else
Expand All @@ -326,9 +330,13 @@ describe('Authentication & Middleware Tests', () => {

it('test_admin_middleware_accepts_admin_token', async () => {
const adminKeypair = makeKeypair();
const now = Math.floor(Date.now() / 1000);
const token = signJwt({
sub: adminKeypair.publicKey(),
exp: Math.floor(Date.now() / 1000) + 3600,
iat: now,
exp: now + 3600,
iss: 'flowfi-api',
aud: 'flowfi-api',
});

process.env.ADMIN_PUBLIC_KEY = adminKeypair.publicKey();
Expand All @@ -343,9 +351,13 @@ describe('Authentication & Middleware Tests', () => {

it('test_admin_middleware_fails_closed_when_key_unset', async () => {
const keypair = makeKeypair();
const now = Math.floor(Date.now() / 1000);
const token = signJwt({
sub: keypair.publicKey(),
exp: Math.floor(Date.now() / 1000) + 3600,
iat: now,
exp: now + 3600,
iss: 'flowfi-api',
aud: 'flowfi-api',
});

// Unset the admin key
Expand All @@ -371,9 +383,13 @@ describe('Authentication & Middleware Tests', () => {

it('test_events_endpoint_allows_authenticated_matching_address', async () => {
const keypair = makeKeypair();
const now = Math.floor(Date.now() / 1000);
const token = signJwt({
sub: keypair.publicKey(),
exp: Math.floor(Date.now() / 1000) + 3600,
iat: now,
exp: now + 3600,
iss: 'flowfi-api',
aud: 'flowfi-api',
});

const res = await request(app)
Expand All @@ -388,9 +404,13 @@ describe('Authentication & Middleware Tests', () => {
it('test_events_endpoint_rejects_authenticated_mismatched_address', async () => {
const keypair = makeKeypair();
const otherKeypair = makeKeypair();
const now = Math.floor(Date.now() / 1000);
const token = signJwt({
sub: keypair.publicKey(),
exp: Math.floor(Date.now() / 1000) + 3600,
iat: now,
exp: now + 3600,
iss: 'flowfi-api',
aud: 'flowfi-api',
});

const res = await request(app)
Expand Down
20 changes: 10 additions & 10 deletions backend/tests/claimable.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ClaimableAmountService } from '../src/services/claimable.service.js';

function makeStreamState(overrides: Partial<Parameters<ClaimableAmountService['getClaimableAmount']>[0]> = {}) {
return {
streamId: 1,
streamId: 1n,
ratePerSecond: '10',
depositedAmount: '100',
withdrawnAmount: '0',
Expand Down Expand Up @@ -35,7 +35,7 @@ describe('ClaimableAmountService', () => {

const result = service.getClaimableAmount({
...makeStreamState({
streamId: 1,
streamId: 1n,
ratePerSecond: '5',
depositedAmount: '500',
withdrawnAmount: '100',
Expand All @@ -61,7 +61,7 @@ describe('ClaimableAmountService', () => {

const result = service.getClaimableAmount({
...makeStreamState({
streamId: 2,
streamId: 2n,
depositedAmount: '1000',
withdrawnAmount: '900',
}),
Expand All @@ -80,7 +80,7 @@ describe('ClaimableAmountService', () => {

const result = service.getClaimableAmount({
...makeStreamState({
streamId: 3,
streamId: 3n,
withdrawnAmount: '100',
isActive: false,
}),
Expand All @@ -98,7 +98,7 @@ describe('ClaimableAmountService', () => {

const result = service.getClaimableAmount({
...makeStreamState({
streamId: 4,
streamId: 4n,
withdrawnAmount: '150',
}),
});
Expand All @@ -114,7 +114,7 @@ describe('ClaimableAmountService', () => {
});

const input = makeStreamState({
streamId: 5,
streamId: 5n,
ratePerSecond: '7',
depositedAmount: '700',
});
Expand Down Expand Up @@ -146,7 +146,7 @@ describe('ClaimableAmountService', () => {
});

const preWithdrawalState = makeStreamState({
streamId: 7,
streamId: 7n,
ratePerSecond: '10',
depositedAmount: '1000',
withdrawnAmount: '0',
Expand All @@ -166,7 +166,7 @@ describe('ClaimableAmountService', () => {
// and lastUpdateTime are advanced on the stream row, exactly as
// handleTokensWithdrawn does in soroban-event-worker.ts.
const postWithdrawalState = makeStreamState({
streamId: 7,
streamId: 7n,
ratePerSecond: '10',
depositedAmount: '1000',
withdrawnAmount: '400',
Expand All @@ -191,7 +191,7 @@ describe('ClaimableAmountService', () => {

const result = service.getClaimableAmount({
...makeStreamState({
streamId: 6,
streamId: 6n,
ratePerSecond: i128Max,
depositedAmount: i128Max,
withdrawnAmount: '42',
Expand Down Expand Up @@ -228,7 +228,7 @@ describe('ClaimableAmountService', () => {

const result = service.getClaimableAmount({
...makeStreamState({
streamId: 10_000 + iteration,
streamId: 10_000n + BigInt(iteration),
ratePerSecond: rate.toString(),
depositedAmount: deposited.toString(),
withdrawnAmount: withdrawn.toString(),
Expand Down
4 changes: 2 additions & 2 deletions backend/tests/eventRace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('Action Controller vs Worker Event Write Race Guard (Issue #831)', () =

it('withdrawHandler uses upsert on transactionHash_eventType preventing P2002 duplicate crashes when worker processes event first', async () => {
const mockStream = {
streamId: 100,
streamId: 100n,
recipient: 'GRECIPIENT',
withdrawnAmount: '0',
depositedAmount: '1000',
Expand All @@ -77,7 +77,7 @@ describe('Action Controller vs Worker Event Write Race Guard (Issue #831)', () =
},
},
create: expect.objectContaining({
streamId: 100,
streamId: 100n,
eventType: 'WITHDRAWN',
transactionHash: 'tx_race_123',
}),
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/events-wire-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('event wire format', () => {

expect(decodedKeys).toEqual([...allFields].sort());

for (const field of HANDLER_READ_FIELDS[eventName]) {
for (const field of HANDLER_READ_FIELDS[eventName] ?? []) {
expect(decoded).toHaveProperty(field);
}
},
Expand Down
6 changes: 4 additions & 2 deletions backend/tests/indexer-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

const mockFindUnique = vi.fn();
const mockCreate = vi.fn();
const { mockFindUnique, mockCreate } = vi.hoisted(() => ({
mockFindUnique: vi.fn(),
mockCreate: vi.fn(),
}));

vi.mock('../src/lib/prisma.js', () => ({
prisma: {
Expand Down
Loading
Loading