Skip to content
Merged
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
21 changes: 18 additions & 3 deletions backend/src/controllers/token.controller.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
import type { Request, Response } from 'express';
import { getTokenMetadata, listVerifiedTokens } from '../services/token-registry.service.js';
import { sendApiError } from '../types/api-error.js';
import type { TokenMetadata as RegistryTokenMetadata } from '../services/token-registry.service.js';

export function toPublicTokenMetadata(token: RegistryTokenMetadata) {
return {
symbol: token.symbol,
name: token.name,
decimals: token.decimals,
contractAddress: token.contractAddress,
type: token.symbol === 'XLM' ? 'native' : token.symbol.length <= 4 ? 'credit_alphanum4' : 'credit_alphanum12',
...(token.iconUrl ? { icon: token.iconUrl } : {}),
description: token.name,
verified: token.isVerified,
};
}

export async function listTokens(_req: Request, res: Response): Promise<void> {
try { res.json({ tokens: await listVerifiedTokens() }); } catch { sendApiError(res, 500, 'token_list_failed', 'Unable to load verified tokens'); }
try { res.json({ tokens: (await listVerifiedTokens()).map(toPublicTokenMetadata) }); } catch { sendApiError(res, 500, 'token_list_failed', 'Unable to load verified tokens'); }
}
export async function getToken(req: Request, res: Response): Promise<void> {
const address = req.params.address;
if (typeof address !== 'string') {
sendApiError(res, 400, 'invalid_token_address', 'A valid Soroban contract address is required');
return;
}
try { res.json({ token: await getTokenMetadata(address) }); } catch (error) {
try { res.json({ token: toPublicTokenMetadata(await getTokenMetadata(address)) }); } catch (error) {
const apiError = error as { statusCode?: number; code?: string; message?: string };
if (apiError.statusCode) { sendApiError(res, apiError.statusCode, apiError.code ?? 'token_lookup_failed', apiError.message ?? 'Unable to resolve token'); return; }
sendApiError(res, 500, 'token_lookup_failed', 'Unable to resolve token metadata');
}
}
}
12 changes: 12 additions & 0 deletions backend/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,18 @@ export const exportTransactions = async (
return sendApiError(res, 400, "INVALID_DIRECTION", "Invalid direction. Must be incoming, outgoing, or all");
}

// Validate the complete range before calling either streaming exporter:
// they write headers and a response prefix before querying the database.
if (startDate && Number.isNaN(startDate.getTime())) {
return sendApiError(res, 400, "INVALID_START_DATE", "startDate must be a valid date");
}
if (endDate && Number.isNaN(endDate.getTime())) {
return sendApiError(res, 400, "INVALID_END_DATE", "endDate must be a valid date");
}
if (startDate && endDate && startDate.getTime() > endDate.getTime()) {
return sendApiError(res, 400, "INVALID_DATE_RANGE", "startDate must be earlier than or equal to endDate");
}

const options: exportService.ExportOptions = {
format: format as "csv" | "json",
direction,
Expand Down
48 changes: 22 additions & 26 deletions backend/src/routes/v1/token.routes.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { Router, type Request, type Response } from 'express';
import { getToken } from '../../controllers/token.controller.js';
import { getToken, toPublicTokenMetadata } from '../../controllers/token.controller.js';
import { createHash } from 'node:crypto';
import {
getTokenMetadataUpdatedAt,
listVerifiedTokens,
type TokenMetadataList,
} from '../../lib/token-metadata.js';
import { listVerifiedTokens } from '../../services/token-registry.service.js';
import { sendApiError } from '../../types/api-error.js';

const router = Router();

Expand All @@ -18,13 +15,6 @@ const router = Router();
export const TOKEN_CACHE_CONTROL =
'public, max-age=3600, stale-while-revalidate=86400';

function buildPayload(): TokenMetadataList {
return {
tokens: listVerifiedTokens(),
updatedAt: getTokenMetadataUpdatedAt(),
};
}

/**
* Strong ETag derived from the serialized payload. Anything that changes the
* metadata (or its version marker) changes the hash, so a client revalidating
Expand Down Expand Up @@ -86,21 +76,27 @@ export function etagMatches(ifNoneMatch: string | undefined, etag: string): bool
* 304:
* description: Not Modified - the caller's cached copy is still current
*/
router.get('/', (req: Request, res: Response) => {
const body = JSON.stringify(buildPayload());
const etag = computeTokensEtag(body);
router.get('/', async (req: Request, res: Response) => {
try {
// Reuse the same persisted verified registry as detail lookups. The
// registry rows carry updatedAt, which makes the ETag change on writes.
const registryTokens = await listVerifiedTokens();
const tokens = registryTokens.map(toPublicTokenMetadata);
const updatedAt = registryTokens.reduce<Date>((latest, token) =>
token.updatedAt > latest ? token.updatedAt : latest, new Date(0));
const body = JSON.stringify({ tokens, updatedAt: updatedAt.toISOString() });
const etag = computeTokensEtag(body);

res.setHeader('Cache-Control', TOKEN_CACHE_CONTROL);
res.setHeader('ETag', etag);

if (etagMatches(req.headers['if-none-match'], etag)) {
// 304 must not carry a body. Repeated headers are required so an
// intermediary does not strip the cache metadata from the 304.
res.status(304).end();
return;
res.setHeader('Cache-Control', TOKEN_CACHE_CONTROL);
res.setHeader('ETag', etag);
if (etagMatches(req.headers['if-none-match'], etag)) {
res.status(304).end();
return;
}
res.status(200).type('application/json').send(body);
} catch {
sendApiError(res, 500, 'token_list_failed', 'Unable to load verified tokens');
}

res.status(200).type('application/json').send(body);
});

router.get('/:address', getToken);
Expand Down
19 changes: 15 additions & 4 deletions backend/src/services/token-registry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ApiError } from '../types/api-error.js';

const CACHE_TTL_SECONDS = 24 * 60 * 60;
const CACHE_PREFIX = 'token-registry:v1';
type TokenMetadata = {
export type TokenMetadata = {
id: string; contractAddress: string; symbol: string; name: string; decimals: number;
iconUrl: string | null; issuerAddress: string | null; isVerified: boolean; createdAt: Date; updatedAt: Date;
};
Expand Down Expand Up @@ -57,8 +57,19 @@ export async function listVerifiedTokens(): Promise<TokenMetadata[]> {
}
export async function getTokenMetadata(address: string): Promise<TokenMetadata> {
if (!StrKey.isValidContract(address)) throw new ApiError(400, 'invalid_token_address', 'A valid Soroban contract address is required');
const cached = await redisGet(address); if (cached) return cached;
const existing = await prisma.tokenRegistry.findUnique({ where: { contractAddress: address } });
// PostgreSQL is authoritative for entries in the registry. Checking it
// before Redis prevents a stale metadata cache from disagreeing with the
// verified list after an administrative insert or update.
let existing: TokenMetadata | null;
try {
existing = await prisma.tokenRegistry.findUnique({ where: { contractAddress: address } });
} catch {
// Redis remains a short-lived fallback during database outages, but never
// overrides a successful database lookup or a known registry deletion.
const cached = await redisGet(address);
if (cached) return cached;
throw new ApiError(503, 'token_registry_unavailable', 'Token registry is temporarily unavailable');
}
if (existing) { await redisSet(existing); return existing; }
try {
const [symbolValue, decimalsValue] = await Promise.all([invoke(address, 'symbol'), invoke(address, 'decimals')]);
Expand All @@ -77,4 +88,4 @@ export async function getTokenMetadata(address: string): Promise<TokenMetadata>
} catch {
throw new ApiError(502, 'token_resolution_failed', 'Unable to resolve token metadata from the Stellar network');
}
}
}
1 change: 0 additions & 1 deletion backend/tests/export.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
formatAmount,
getProtocolFeeForEvent,
mapEventToTransaction,
streamTransactionCSV,
Expand Down
6 changes: 3 additions & 3 deletions backend/tests/graphql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ describe("GraphQL API", () => {
const response = await request(app)
.post("/graphql")
.send({
query: "query { stream(streamId: 1) { id streamId sender recipient } }",
query: 'query { stream(streamId: "1") { id streamId sender recipient } }',
});

expect(response.status).toBe(200);
Expand Down Expand Up @@ -316,7 +316,7 @@ describe("GraphQL API", () => {
describe("Issue #1699: Accept full-width stream identifiers in GraphQL schema", () => {
it("queries stream with full-width u64 ID (> 2^31 - 1)", async () => {
const largeStreamId = "9223372036854775807"; // 2^63 - 1
prismaMock.stream.findUnique.mockResolvedValueOnce({
prismaMock.stream.findMany.mockResolvedValueOnce([{
id: "stream-large",
streamId: BigInt(largeStreamId),
sender: ADDRESS,
Expand All @@ -329,7 +329,7 @@ describe("GraphQL API", () => {
endTime: null,
isActive: true,
isPaused: false,
});
}]);

const response = await request(app).post("/graphql").send({
query: `query { stream(streamId: "${largeStreamId}") { id streamId sender recipient } }`,
Expand Down
11 changes: 11 additions & 0 deletions backend/tests/integration/stream-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ const {
mockPauseStream,
mockResumeStream,
mockWithdraw,
mockGetClaimableAmount,
mockPrisma,
} = vi.hoisted(() => ({
mockPauseStream: vi.fn(),
mockResumeStream: vi.fn(),
mockWithdraw: vi.fn(),
mockGetClaimableAmount: vi.fn(),
mockPrisma: {
stream: {
findUnique: vi.fn(),
Expand Down Expand Up @@ -45,6 +47,11 @@ vi.mock('../../src/services/sorobanService.js', () => ({
withdraw: mockWithdraw,
}));

vi.mock('../../src/services/claimable.service.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/services/claimable.service.js')>()),
claimableAmountService: { getClaimableAmount: mockGetClaimableAmount },
}));

import app from '../../src/app.js';

function makeKeypair() {
Expand Down Expand Up @@ -199,6 +206,10 @@ describe('stream action routes', () => {
updatedAt: new Date(),
});
mockWithdraw.mockResolvedValue({ txHash: 'withdraw-tx-hash' });
mockGetClaimableAmount.mockReturnValue({
claimableAmount: '100',
actionable: true,
});
mockPrisma.stream.update.mockResolvedValue({
streamId: 11,
withdrawnAmount: '600',
Expand Down
33 changes: 25 additions & 8 deletions backend/tests/token-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';

const registry = vi.hoisted(() => ({ tokens: [] as Array<Record<string, unknown>> }));
vi.mock('../src/services/token-registry.service.js', () => ({
listVerifiedTokens: vi.fn(async () => registry.tokens),
getTokenMetadata: vi.fn(async (address: string) => {
const token = registry.tokens.find((entry) => entry.contractAddress === address);
if (!token) throw Object.assign(new Error('not found'), { statusCode: 404 });
return token;
}),
}));

vi.mock('../src/logger.js', () => ({
default: { error: vi.fn(), info: vi.fn(), warn: vi.fn(), debug: vi.fn() },
}));
Expand All @@ -22,6 +32,11 @@ function mountTokensApp(): express.Express {
describe('GET /v1/tokens', () => {
beforeEach(() => {
resetTokenMetadata();
registry.tokens = [
{ id: '3', contractAddress: 'CCWAMYJME4YOIUNAKVYEBYOG5I65QMKEX2NMN4OJAPXRPIF24ONPSHY', symbol: 'EURC', name: 'Euro Coin', decimals: 7, iconUrl: null, issuerAddress: 'GISSUER2', isVerified: true, createdAt: new Date(0), updatedAt: new Date('2026-01-01') },
{ id: '2', contractAddress: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA', symbol: 'USDC', name: 'USD Coin', decimals: 7, iconUrl: 'https://example.test/usdc.svg', issuerAddress: 'GISSUER', isVerified: true, createdAt: new Date(0), updatedAt: new Date('2026-01-01') },
{ id: '1', contractAddress: 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCN', symbol: 'XLM', name: 'Stellar Lumens', decimals: 7, iconUrl: null, issuerAddress: null, isVerified: true, createdAt: new Date(0), updatedAt: new Date('2026-01-01') },
];
});

it('returns verified token metadata', async () => {
Expand Down Expand Up @@ -99,14 +114,7 @@ describe('GET /v1/tokens', () => {
const first = await request(mountTokensApp()).get('/v1/tokens');
const oldEtag = String(first.headers['etag']);

upsertTokenMetadata({
symbol: 'USDC',
name: 'USD Coin (updated)',
decimals: 7,
contractAddress: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA',
type: 'credit_alphanum4',
verified: true,
});
registry.tokens[1] = { ...registry.tokens[1]!, name: 'USD Coin (updated)', updatedAt: new Date('2026-01-02') };

const afterUpdate = await request(mountTokensApp()).get('/v1/tokens');
expect(afterUpdate.headers['etag']).not.toBe(oldEtag);
Expand All @@ -121,6 +129,15 @@ describe('GET /v1/tokens', () => {
);
expect(usdc.name).toBe('USD Coin (updated)');
});

it('returns the same persisted token metadata and verification status from list and detail', async () => {
const listed = await request(mountTokensApp()).get('/v1/tokens');
const detail = await request(mountTokensApp()).get('/v1/tokens/CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA');
const item = listed.body.tokens.find((token: { symbol: string }) => token.symbol === 'USDC');
expect(detail.status).toBe(200);
expect(detail.body.token).toEqual(item);
expect(item).toMatchObject({ symbol: 'USDC', verified: true, name: 'USD Coin' });
});
});

describe('token metadata store', () => {
Expand Down
1 change: 0 additions & 1 deletion backend/tests/token-routes.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ describe('GET /v1/tokens (versioned mount)', () => {
expect(res.headers['cache-control']).toContain('max-age=3600');
expect(res.headers['etag']).toBeDefined();
expect(Array.isArray(res.body.tokens)).toBe(true);
expect(res.body.tokens.length).toBeGreaterThan(0);
});

it('returns 304 Not Modified for a matching If-None-Match', async () => {
Expand Down
46 changes: 45 additions & 1 deletion backend/tests/user.controller.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { registerUser, getUser, getUserEvents, getCurrentUser } from '../src/controllers/user.controller.js';
import { registerUser, getUser, getUserEvents, getCurrentUser, exportTransactions } from '../src/controllers/user.controller.js';
import * as exportService from '../src/services/export.service.js';
import { prisma } from '../src/lib/prisma.js';
import type { Request, Response } from 'express';

Expand Down Expand Up @@ -289,4 +290,47 @@ describe('User Controller', () => {
}));
});
});

describe('exportTransactions date range validation', () => {
const address = 'GD2XP6FNWL6IWULVMPNA2RV2T7GLCJHK3RH75GBCY7TSVIWDITJN4FXJ';

beforeEach(() => {
vi.spyOn(exportService, 'streamTransactionCSV').mockResolvedValue();
vi.spyOn(exportService, 'streamTransactionJSON').mockResolvedValue();
req.params = { address };
});

it.each(['csv', 'json'])('allows omitted dates for %s exports', async (format) => {
req.query = { format };
await exportTransactions(req as Request, res as Response, next);
expect(res.status).not.toHaveBeenCalledWith(400);
expect(format === 'csv' ? exportService.streamTransactionCSV : exportService.streamTransactionJSON)
.toHaveBeenCalled();
});

it.each(['csv', 'json'])('rejects malformed and reversed ranges before starting %s exports', async (format) => {
req.query = { format, startDate: 'not-a-date', endDate: '2026-01-01' };
await exportTransactions(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(exportService.streamTransactionCSV).not.toHaveBeenCalled();
expect(exportService.streamTransactionJSON).not.toHaveBeenCalled();

vi.clearAllMocks();
req.query = { format, startDate: '2026-02-01', endDate: '2026-01-01' };
await exportTransactions(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(exportService.streamTransactionCSV).not.toHaveBeenCalled();
expect(exportService.streamTransactionJSON).not.toHaveBeenCalled();
});

it.each(['csv', 'json'])('passes a valid inclusive date range to the %s exporter', async (format) => {
req.query = { format, startDate: '2026-01-01', endDate: '2026-01-31' };
await exportTransactions(req as Request, res as Response, next);
const exporter = format === 'csv' ? exportService.streamTransactionCSV : exportService.streamTransactionJSON;
expect(exporter).toHaveBeenCalledWith(address, expect.objectContaining({
startDate: new Date('2026-01-01'),
endDate: new Date('2026-01-31'),
}), res);
});
});
});
19 changes: 19 additions & 0 deletions frontend/src/__tests__/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,25 @@ describe('RecipientStep', () => {
fireEvent.blur(screen.getByRole('textbox'));
expect(onChange).toHaveBeenCalledWith(VALID_KEY);
});

it('shows validation feedback after a malformed value is edited', () => {
const RecipientHarness = () => {
const [value, setValue] = React.useState("");
return <RecipientStep value={value} onChange={setValue} />;
};
render(<RecipientHarness />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'not-a-key' } });
expect(screen.getByRole('alert')).toHaveTextContent(/invalid stellar public key/i);
});

it('preserves the current input when a paste contains only whitespace', () => {
const onChange = vi.fn();
render(<RecipientStep value="GABC" onChange={onChange} />);
fireEvent.paste(screen.getByRole('textbox'), {
clipboardData: { getData: () => ' ' },
});
expect(onChange).not.toHaveBeenCalled();
});
});

// ─── AmountStep ──────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -764,7 +764,11 @@ describe("StreamDetailsContent claimable re-sync + pulse", () => {
await renderStream();

const firstPulse = screen.getByTestId("claimable-tick-pulse");
const pulse = vi.mocked(window.setInterval).mock.calls.find(([, delay]) => delay === 1000);
const pulse = await waitFor(() => {
const registeredInterval = vi.mocked(window.setInterval).mock.calls.find(([, delay]) => delay === 1000);
expect(registeredInterval).toBeTruthy();
return registeredInterval;
});
expect(pulse).toBeTruthy();

act(() => {
Expand Down
Loading
Loading