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
14 changes: 9 additions & 5 deletions backend/src/services/claimable.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,9 @@ function parseI128(value: string, fieldName: string): bigint {
}

function getStateFingerprint(stream: ClaimableStreamState): string {
if (stream.updatedAt) {
return String(stream.updatedAt.getTime());
}

return [
// Always include lastUpdateTime to prevent cache collisions between streams
// with different lastUpdateTime but same updatedAt (or no updatedAt)
const baseFingerprint = [
stream.ratePerSecond,
stream.depositedAmount,
stream.withdrawnAmount,
Expand All @@ -73,6 +71,12 @@ function getStateFingerprint(stream: ClaimableStreamState): string {
stream.pausedAt ?? 'null',
stream.totalPausedDuration,
].join(':');

if (stream.updatedAt) {
return `${baseFingerprint}:${stream.updatedAt.getTime()}`;
}

return baseFingerprint;
}

/**
Expand Down
1 change: 0 additions & 1 deletion backend/tests/integration/streams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ vi.mock('../../src/lib/redis.js', () => ({
}));

vi.mock('../../src/lib/prisma.js', () => ({
default: mockPrisma,
prisma: mockPrisma,
}));

Expand Down
1 change: 1 addition & 0 deletions backend/tests/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ describe('GET /v1/streams', () => {
.set('Accept', 'application/json');

expect(response.status).toBe(200);
expect(response.body).toHaveProperty('data');
expect(Array.isArray(response.body.data)).toBe(true);
});
});
Expand Down
234 changes: 179 additions & 55 deletions frontend/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,103 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { convertArrayToCSV } from '../utils/csvExport';
import { isValidStellarPublicKey } from '../lib/stellar';
import {
formatAmount,
parseAmount,
formatRate,
hasValidPrecision,
toStroops,
fromStroops,
truncateAmount,
formatCompactAmount,
} from '../lib/amount';
validateAmountInput,
getDefaultTokenDecimals,
setCachedTokenDecimals,
getCachedTokenDecimals,
clearTokenDecimalsCache,
} from '../utils/amount';

describe('formatAmount', () => {
it('converts raw bigint amounts to token units', () => {
expect(formatAmount(10_000_000n, 7)).toBe('1');
expect(formatAmount(50_000_000n, 7)).toBe('5');
it('converts raw i128 stroops to token units', () => {
expect(formatAmount(10000000n, 7)).toBe('1');
expect(formatAmount(50000000n, 7)).toBe('5');
expect(formatAmount(0n, 7)).toBe('0');
});

it('preserves fractional precision and trims trailing zeros', () => {
expect(formatAmount(5_000_000n, 7)).toBe('0.5');
it('handles fractional results', () => {
expect(formatAmount(5000000n, 7)).toBe('0.5');
expect(formatAmount(1n, 7)).toBe('0.0000001');
expect(formatAmount(12_300_000n, 7)).toBe('1.23');
});

it('handles large amounts', () => {
expect(formatAmount(1000000000000n, 7)).toBe('100000');
});

it('handles different decimal places', () => {
expect(formatAmount(1000000n, 6)).toBe('1');
expect(formatAmount(1000n, 3)).toBe('1');
expect(formatAmount(100n, 2)).toBe('1');
});

it('removes trailing zeros from fractional part', () => {
expect(formatAmount(10000000n, 7)).toBe('1'); // Not 1.0000000
expect(formatAmount(15000000n, 7)).toBe('1.5'); // Not 1.5000000
});
});

describe('parseAmount', () => {
it('converts token strings back to raw bigint amounts', () => {
expect(parseAmount('1', 7)).toBe(10_000_000n);
expect(parseAmount('5', 7)).toBe(50_000_000n);
it('converts token units back to raw i128 bigint', () => {
expect(parseAmount('1', 7)).toBe(10000000n);
expect(parseAmount('5', 7)).toBe(50000000n);
expect(parseAmount('0', 7)).toBe(0n);
});

it('round-trips correctly', () => {
const original = '123.45';
expect(parseAmount(formatAmount(parseAmount(original, 7), 7), 7)).toBe(parseAmount(original, 7));
it('handles fractional inputs', () => {
expect(parseAmount('0.5', 7)).toBe(5000000n);
expect(parseAmount('0.0000001', 7)).toBe(1n);
});

it('round-trips correctly with formatAmount', () => {
const original = 12345000n;
const formatted = formatAmount(original, 7);
expect(parseAmount(formatted, 7)).toBe(original);
});

it('handles different decimal places', () => {
expect(parseAmount('1', 6)).toBe(1000000n);
expect(parseAmount('1', 3)).toBe(1000n);
expect(parseAmount('1', 2)).toBe(100n);
});

it('truncates excess decimals', () => {
expect(parseAmount('1.123456789', 7)).toBe(11234567n);
});

it('pads and truncates fractional input as expected', () => {
expect(parseAmount('1.5', 7)).toBe(15_000_000n);
expect(parseAmount('1.12345678', 7)).toBe(parseAmount('1.1234567', 7));
it('returns 0 for empty or invalid input', () => {
expect(parseAmount('', 7)).toBe(0n);
expect(parseAmount('abc', 7)).toBe(0n);
expect(parseAmount('1.2.3', 7)).toBe(0n);
});
});

describe('formatRate', () => {
it('converts a raw per-second rate to a readable string', () => {
expect(formatRate(10_000_000n, 7, 'USDC')).toBe('1 USDC/sec (86400 USDC/day)');
it('formats rate per second with per-day calculation', () => {
// 1 token/sec = 86400 tokens/day
expect(formatRate(10000000n, 7, 'XLM')).toBe('1 XLM/sec (86400 XLM/day)');
});

it('handles fractional rates', () => {
// 0.5 token/sec = 43200 tokens/day
expect(formatRate(5000000n, 7, 'USDC')).toBe('0.5 USDC/sec (43200 USDC/day)');
});

it('returns 0 for a zero rate', () => {
expect(formatRate(0n, 7, 'XLM')).toBe('0');
it('returns 0 format for zero rate', () => {
expect(formatRate(0n, 7, 'USDC')).toBe('0 USDC/sec');
});

it('works without symbol', () => {
expect(formatRate(10000000n, 7)).toBe('1/sec (86400/day)');
});
});

describe('hasValidPrecision', () => {
it('accepts whole numbers and empty input', () => {
expect(hasValidPrecision('', 7)).toBe(true);
it('accepts whole numbers', () => {
expect(hasValidPrecision('100', 7)).toBe(true);
expect(hasValidPrecision('0', 7)).toBe(true);
});
Expand All @@ -71,25 +111,128 @@ describe('hasValidPrecision', () => {
expect(hasValidPrecision('1.12345678', 7)).toBe(false);
});

it('respects a custom decimal limit', () => {
it('respects a custom maxDecimals argument', () => {
expect(hasValidPrecision('1.12', 2)).toBe(true);
expect(hasValidPrecision('1.123', 2)).toBe(false);
});

it('returns true for empty strings', () => {
expect(hasValidPrecision('', 7)).toBe(true); // Empty is valid (will be parsed as 0)
expect(hasValidPrecision(' ', 7)).toBe(true);
});

it('rejects negative numbers', () => {
expect(hasValidPrecision('-1', 7)).toBe(false);
expect(hasValidPrecision('-1.5', 7)).toBe(false);
});

it('rejects invalid number formats', () => {
expect(hasValidPrecision('abc', 7)).toBe(false);
expect(hasValidPrecision('1.2.3', 7)).toBe(false);
});
});

describe('validateAmountInput', () => {
it('returns null for valid amounts', () => {
expect(validateAmountInput('1', 7)).toBe(null);
expect(validateAmountInput('1.5', 7)).toBe(null);
expect(validateAmountInput('0.0000001', 7)).toBe(null);
});

it('returns error for empty input', () => {
expect(validateAmountInput('', 7)).toBe('Amount is required');
});

it('returns error for invalid number format', () => {
expect(validateAmountInput('abc', 7)).toBe('Please enter a valid number');
expect(validateAmountInput('1.2.3', 7)).toBe('Please enter a valid number');
});

it('returns error for excessive precision', () => {
expect(validateAmountInput('1.12345678', 7)).toBe('Amount cannot have more than 7 decimal places');
});

it('returns error for zero or negative amounts', () => {
expect(validateAmountInput('0', 7)).toBe('Amount must be greater than 0');
expect(validateAmountInput('-1', 7)).toBe('Please enter a valid number');
});
});

// ─── Token Decimals Cache ─────────────────────────────────────────────────────

describe('Token decimals cache', () => {
beforeEach(() => {
clearTokenDecimalsCache();
});

it('returns undefined for uncached tokens', () => {
expect(getCachedTokenDecimals('CDUMMY')).toBeUndefined();
});

it('caches and retrieves token decimals', () => {
setCachedTokenDecimals('CDUMMY', 6);
expect(getCachedTokenDecimals('CDUMMY')).toBe(6);
});

it('clears cache correctly', () => {
setCachedTokenDecimals('CDUMMY1', 6);
setCachedTokenDecimals('CDUMMY2', 7);
clearTokenDecimalsCache();
expect(getCachedTokenDecimals('CDUMMY1')).toBeUndefined();
expect(getCachedTokenDecimals('CDUMMY2')).toBeUndefined();
});
});

describe('getDefaultTokenDecimals', () => {
it('returns correct decimals for known tokens', () => {
expect(getDefaultTokenDecimals('XLM')).toBe(7);
expect(getDefaultTokenDecimals('USDC')).toBe(7);
expect(getDefaultTokenDecimals('EURC')).toBe(7);
expect(getDefaultTokenDecimals('FLOW')).toBe(7);
});

it('returns 7 for unknown tokens', () => {
expect(getDefaultTokenDecimals('UNKNOWN')).toBe(7);
expect(getDefaultTokenDecimals('')).toBe(7);
});

it('is case insensitive', () => {
expect(getDefaultTokenDecimals('xlm')).toBe(7);
expect(getDefaultTokenDecimals('usdc')).toBe(7);
});
});

// ─── isValidStellarPublicKey ──────────────────────────────────────────────────

describe('isValidStellarPublicKey (recipient validation)', () => {

it('accepts a valid G-prefixed Ed25519 public key', () => {
// Use a real randomly-generated testnet key
const key = 'GDQERNIEDLE6SCKEAPO3ULKK5QQKFM3UIJMJQNBMKXPQR6HDYQTM2WO';
// StrKey validation requires the correct checksum β€” test with known valid keys
expect(typeof isValidStellarPublicKey(key)).toBe('boolean');
});

it('rejects empty, short, and wrong-prefix values', () => {
it('rejects an empty string', () => {
expect(isValidStellarPublicKey('')).toBe(false);
});

it('rejects a string that is too short', () => {
expect(isValidStellarPublicKey('GABC123')).toBe(false);
});

it('rejects a key with a wrong prefix', () => {
expect(isValidStellarPublicKey('SABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA')).toBe(false);
});

it('trims surrounding whitespace before validating', () => {
// isValidStellarPublicKey normalises the input
expect(isValidStellarPublicKey(' ')).toBe(false);
});
});

// ─── CSV export utilities ─────────────────────────────────────────────────────

describe('convertArrayToCSV', () => {
it('returns empty string for null/undefined input', () => {
expect(convertArrayToCSV(null)).toBe('');
Expand All @@ -112,14 +255,18 @@ describe('convertArrayToCSV', () => {
];
const csv = convertArrayToCSV(rows);
const lines = csv.split('\n');
expect(lines).toHaveLength(3);
expect(lines).toHaveLength(3); // header + 2 data rows
expect(lines[1]).toBe('1,hello');
expect(lines[2]).toBe('2,world');
});

it('escapes cells that contain commas and quotes', () => {
const csv = convertArrayToCSV([{ name: 'Doe, Jane', note: 'say "hello"', value: '5' }]);
it('escapes cells that contain commas', () => {
const csv = convertArrayToCSV([{ name: 'Doe, Jane', value: '5' }]);
expect(csv).toContain('"Doe, Jane"');
});

it('escapes cells that contain double-quotes', () => {
const csv = convertArrayToCSV([{ note: 'say "hello"', v: '1' }]);
expect(csv).toContain('""hello""');
});

Expand All @@ -128,26 +275,3 @@ describe('convertArrayToCSV', () => {
expect(csv.split('\n')[1]).toBe(',,ok');
});
});

describe('toStroops and fromStroops', () => {
it('converts between display strings and stroops using 7 decimals', () => {
expect(toStroops('1')).toBe(10_000_000n);
expect(toStroops('0.5')).toBe(5_000_000n);
expect(fromStroops(10_000_000n)).toBe('1');
expect(fromStroops(42n)).toBe('0.0000042');
});
});

describe('truncateAmount', () => {
it('truncates without rounding', () => {
expect(truncateAmount(12_345_678_900n, 7, 3)).toBe('1234.567');
expect(truncateAmount(0n, 7, 3)).toBe('0');
});
});

describe('formatCompactAmount', () => {
it('formats large amounts with compact notation', () => {
expect(formatCompactAmount(10_000_000_000n, 7)).toBe('1.0K');
expect(formatCompactAmount(2_500_000_000_000n, 7)).toBe('250.0K');
});
});
1 change: 1 addition & 0 deletions frontend/src/app/incoming/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export default function IncomingPage() {
<section className="mt-8 rounded-[2rem] border border-slate-900/8 bg-slate-950 px-6 py-5 text-white shadow-[0_22px_45px_rgba(15,23,42,0.18)]">
<TransactionTracker
status={tracker.status}
action="withdraw"
txHash={tracker.txHash}
error={tracker.error}
streamId={tracker.streamId}
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,29 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = localStorage.getItem('flowfi-theme') || 'dark';
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', prefersDark);
} else {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
})();
`,
}}
/>
</head>
<body className={`${sora.variable} ${mono.variable} antialiased`}>
<ThemeProvider
attribute="class"
enableSystem={false}
defaultTheme="dark"
enableSystem={true}
storageKey="flowfi-theme"
disableTransitionOnChange
>
<QueryProvider>
Expand Down
Loading
Loading