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
239 changes: 239 additions & 0 deletions frontend/src/__tests__/lib.amount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { describe, it, expect } from 'vitest';
import {
formatAmount,
parseAmount,
formatRate,
hasValidPrecision,
truncateAmount,
formatCompactAmount,
toStroops,
fromStroops,
} from '../lib/amount';

describe('lib/amount.ts - formatAmount', () => {
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('handles fractional results', () => {
expect(formatAmount(5000000n, 7)).toBe('0.5');
expect(formatAmount(1n, 7)).toBe('0.0000001');
});

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
});

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

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

describe('lib/amount.ts - parseAmount', () => {
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('handles fractional inputs', () => {
expect(parseAmount('0.5', 7)).toBe(5000000n);
expect(parseAmount('0.0000001', 7)).toBe(1n);
});

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

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

it('returns 0 for empty or invalid input', () => {
expect(parseAmount('', 7)).toBe(0n);
expect(parseAmount(' ', 7)).toBe(0n);
});
});

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

it('round-trips various amounts', () => {
const testCases = [1n, 100n, 1000000n, 10000000n, 123456789n, 1000000000000n];
testCases.forEach(amount => {
const formatted = formatAmount(amount, 7);
expect(parseAmount(formatted, 7)).toBe(amount);
});
});
});

describe('lib/amount.ts - formatRate', () => {
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 format for zero rate', () => {
expect(formatRate(0n, 7)).toBe('0');
});

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

it('handles very small rates', () => {
expect(formatRate(1n, 7, 'USDC')).toBe('0.0000001 USDC/sec (0.0086400 USDC/day)');

Check failure on line 105 in frontend/src/__tests__/lib.amount.test.ts

View workflow job for this annotation

GitHub Actions / Frontend CI

src/__tests__/lib.amount.test.ts > lib/amount.ts - formatRate > handles very small rates

AssertionError: expected '0.0000001 USDC/sec (0.00864 USDC/day)' to be '0.0000001 USDC/sec (0.0086400 USDC/da…' // Object.is equality Expected: "0.0000001 USDC/sec (0.0086400 USDC/day)" Received: "0.0000001 USDC/sec (0.00864 USDC/day)" ❯ src/__tests__/lib.amount.test.ts:105:39
});
});

describe('lib/amount.ts - hasValidPrecision', () => {
it('accepts whole numbers', () => {
expect(hasValidPrecision('100', 7)).toBe(true);
expect(hasValidPrecision('0', 7)).toBe(true);
});

it('accepts values within the decimal limit', () => {
expect(hasValidPrecision('1.234', 7)).toBe(true);
expect(hasValidPrecision('1.1234567', 7)).toBe(true);
});

it('rejects values exceeding the decimal limit', () => {
expect(hasValidPrecision('1.12345678', 7)).toBe(false);
});

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);
expect(hasValidPrecision(' ', 7)).toBe(true);
});

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

describe('lib/amount.ts - truncateAmount', () => {
it('truncates to specified decimal places without rounding', () => {
// 1.23456789 truncated to 4 decimals = 1.2345
expect(truncateAmount(123456789n, 8, 4)).toBe('1.2345');
});

it('removes trailing zeros after truncation', () => {
expect(truncateAmount(1200000n, 7, 4)).toBe('0.12');
});

it('returns whole number when no fractional part', () => {
expect(truncateAmount(10000000n, 7, 4)).toBe('1');
});

it('handles zero amount', () => {
expect(truncateAmount(0n, 7, 4)).toBe('0');
});

it('truncates to 1 decimal place', () => {
expect(truncateAmount(123456789n, 8, 1)).toBe('1.2');
});
});

describe('lib/amount.ts - formatCompactAmount', () => {
it('displays whole numbers as-is', () => {
expect(formatCompactAmount(100n, 0)).toBe('100');
expect(formatCompactAmount(999n, 0)).toBe('999');
});

it('formats thousands with K', () => {
expect(formatCompactAmount(1500000n, 0)).toBe('1.5K');

Check failure on line 170 in frontend/src/__tests__/lib.amount.test.ts

View workflow job for this annotation

GitHub Actions / Frontend CI

src/__tests__/lib.amount.test.ts > lib/amount.ts - formatCompactAmount > formats thousands with K

AssertionError: expected '1.5M' to be '1.5K' // Object.is equality Expected: "1.5K" Received: "1.5M" ❯ src/__tests__/lib.amount.test.ts:170:46
expect(formatCompactAmount(1000000n, 0)).toBe('1.0K');
});

it('formats millions with M', () => {
expect(formatCompactAmount(1500000000n, 0)).toBe('1.5M');

Check failure on line 175 in frontend/src/__tests__/lib.amount.test.ts

View workflow job for this annotation

GitHub Actions / Frontend CI

src/__tests__/lib.amount.test.ts > lib/amount.ts - formatCompactAmount > formats millions with M

AssertionError: expected '1.5B' to be '1.5M' // Object.is equality Expected: "1.5M" Received: "1.5B" ❯ src/__tests__/lib.amount.test.ts:175:49
expect(formatCompactAmount(1000000000n, 0)).toBe('1.0M');
});

it('formats billions with B', () => {
expect(formatCompactAmount(1500000000000n, 0)).toBe('1.5B');

Check failure on line 180 in frontend/src/__tests__/lib.amount.test.ts

View workflow job for this annotation

GitHub Actions / Frontend CI

src/__tests__/lib.amount.test.ts > lib/amount.ts - formatCompactAmount > formats billions with B

AssertionError: expected '1500.0B' to be '1.5B' // Object.is equality Expected: "1.5B" Received: "1500.0B" ❯ src/__tests__/lib.amount.test.ts:180:52
});

it('respects token decimals', () => {
// 1000 XLM (1000 * 10^7)
expect(formatCompactAmount(10000000000n, 7)).toBe('1.0K');
});

it('returns 0 for zero amount', () => {
expect(formatCompactAmount(0n, 7)).toBe('0');
});
});

describe('lib/amount.ts - toStroops and fromStroops', () => {
it('toStroops converts XLM to stroops (7 decimal places)', () => {
expect(toStroops('1')).toBe(10000000n);
expect(toStroops('0.5')).toBe(5000000n);
});

it('fromStroops converts stroops to XLM', () => {
expect(fromStroops(10000000n)).toBe('1');
expect(fromStroops(5000000n)).toBe('0.5');
});

it('toStroops and fromStroops round-trip', () => {
const xlm = '123.4567890';
const stroops = toStroops(xlm);
const restored = fromStroops(stroops);
expect(restored).toBe('123.456789');
});
});

describe('lib/amount.ts - Regression tests for wizard validation', () => {
it('hasValidPrecision rejects amounts with too many decimals for 7-decimal token', () => {
expect(hasValidPrecision('0.12345678', 7)).toBe(false);
expect(hasValidPrecision('100.99999999', 7)).toBe(false);
});

it('validates amount input as wizard uses it', () => {
// Simulating the wizard validation flow
const userInput = '1000.5';
const decimals = 7;

expect(hasValidPrecision(userInput, decimals)).toBe(true);
const parsed = parseAmount(userInput, decimals);
const formatted = formatAmount(parsed, decimals);
expect(formatted).toBe(userInput);
});

it('formatRate provides correct daily/second breakdown for stream amounts', () => {
// Test case: 100 USDC over 30 days
const totalAmount = parseAmount('100', 7); // 100 * 10^7
const totalSeconds = 30 * 24 * 3600; // 30 days in seconds
const ratePerSecond = totalAmount / BigInt(totalSeconds);

const formatted = formatRate(ratePerSecond, 7, 'USDC');
expect(formatted).toContain('USDC/sec');
expect(formatted).toContain('USDC/day');
});
});
10 changes: 5 additions & 5 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ body {
margin-bottom: 1rem;
border: 1px solid rgba(177, 47, 63, 0.34);
background: rgba(255, 243, 245, 0.9);
color: #8c2230;
color: var(--danger);
border-radius: 0.9rem;
padding: 0.72rem 0.92rem;
display: flex;
Expand Down Expand Up @@ -370,7 +370,7 @@ body {

.secondary-button--danger {
border-color: rgba(177, 47, 63, 0.34);
color: #8f2a38;
color: var(--danger);
background: rgba(255, 241, 244, 0.76);
}

Expand Down Expand Up @@ -675,7 +675,7 @@ body {
}

.activity-item span.is-negative {
color: #b12f3f;
color: var(--danger);
}

.dashboard-empty-state {
Expand Down Expand Up @@ -1519,7 +1519,7 @@ body {
.wallet-dropdown__warning {
margin: 0;
font-size: 0.8rem;
color: #8c2230;
color: var(--danger);
background: rgba(255, 243, 245, 0.9);
border: 1px solid rgba(177, 47, 63, 0.24);
border-radius: 0.65rem;
Expand Down Expand Up @@ -1584,6 +1584,6 @@ body {
}

.dashboard-error-state h3 {
color: #b12f3f;
color: var(--danger);
margin: 0;
}
27 changes: 27 additions & 0 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,33 @@ export const metadata: Metadata = {
title: "FlowFi | Real-time Payment Streams",
description:
"The trustless infrastructure to stream salaries, tokens, and rewards in real-time.",
metadataBase: new URL("https://flowfi.app"),
openGraph: {
title: "FlowFi | Real-time Payment Streams",
description:
"The trustless infrastructure to stream salaries, tokens, and rewards in real-time.",
url: "https://flowfi.app",
siteName: "FlowFi",
images: [
{
url: "/opengraph-image.png",
width: 1200,
height: 630,
alt: "FlowFi - Real-time Payment Streams",
},
],
type: "website",
},
twitter: {
card: "summary_large_image",
title: "FlowFi | Real-time Payment Streams",
description:
"The trustless infrastructure to stream salaries, tokens, and rewards in real-time.",
images: ["/opengraph-image.png"],
},
alternates: {
canonical: "https://flowfi.app",
},
};

export default function RootLayout({
Expand Down
42 changes: 33 additions & 9 deletions frontend/src/components/stream-creation/StreamCreationWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { ScheduleStep } from "./ScheduleStep";
import { TemplateStep, type StreamTemplate } from "./TemplateStep";
import { fetchTokenBalanceDisplay } from "@/lib/soroban";
import { isValidStellarPublicKey } from "@/lib/stellar";
import { TransactionTracker } from "../ui/TransactionTracker";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";

Expand Down Expand Up @@ -513,14 +512,39 @@ export const StreamCreationWizard: React.FC<StreamCreationWizardProps> = ({

{!timeoutError ? (
<>
<TransactionTracker
steps={[
{ id: "1", label: "Sign Transaction", status: "completed" },
{ id: "2", label: "Network Confirmation", status: "completed" },
{ id: "3", label: "Indexer Synchronization", status: "current", description: "Detecting your stream on-chain..." }
]}
className="w-full max-w-sm"
/>
<div className="w-full max-w-sm mx-auto space-y-4">
<div className="flex items-center gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent">
<svg className="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="flex-1">
<p className="font-medium">Sign Transaction</p>
<p className="text-xs text-slate-400">Confirmed</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent">
<svg className="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="flex-1">
<p className="font-medium">Network Confirmation</p>
<p className="text-xs text-slate-400">Confirmed</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 border-accent animate-pulse">
<div className="h-2 w-2 rounded-full bg-accent" />
</div>
<div className="flex-1">
<p className="font-medium text-accent">Indexer Synchronization</p>
<p className="text-xs text-slate-400">Detecting your stream on-chain...</p>
</div>
</div>
</div>
<div className="mt-12 flex flex-col items-center gap-2">
<div className="flex gap-1">
<div className="w-2 h-2 rounded-full bg-accent animate-bounce" style={{ animationDelay: "0s" }} />
Expand Down
Loading
Loading