From 64855954d447fc7446b9d2461955efaaa07fe490 Mon Sep 17 00:00:00 2001 From: Lynndabel Date: Fri, 1 May 2026 13:52:38 +0100 Subject: [PATCH 01/12] Resolve merge conflicts from main - fix NotificationDropdown and utils.test --- frontend/src/__tests__/utils.test.ts | 236 +++++++++---- frontend/src/app/streams/create/page.tsx | 40 ++- .../app/streams/streams/[streamId]/page.tsx | 5 +- .../src/components/NotificationDropdown.tsx | 64 ++-- .../components/stream-creation/TopUpModal.tsx | 28 +- frontend/src/utils/amount.ts | 312 ++++++++++++++++++ 6 files changed, 571 insertions(+), 114 deletions(-) create mode 100644 frontend/src/utils/amount.ts diff --git a/frontend/src/__tests__/utils.test.ts b/frontend/src/__tests__/utils.test.ts index 47f1342..470e087 100644 --- a/frontend/src/__tests__/utils.test.ts +++ b/frontend/src/__tests__/utils.test.ts @@ -1,63 +1,103 @@ -import { describe, it, expect } from 'vitest'; -import { convertArrayToCSV } from '../utils/csvExport'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { convertArrayToCSV, downloadCSV } 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); }); @@ -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('Amount must be greater than 0'); + }); +}); + +// ─── 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(''); @@ -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""'); }); @@ -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'); - }); -}); diff --git a/frontend/src/app/streams/create/page.tsx b/frontend/src/app/streams/create/page.tsx index 7c418ef..1fbec08 100644 --- a/frontend/src/app/streams/create/page.tsx +++ b/frontend/src/app/streams/create/page.tsx @@ -1,19 +1,22 @@ "use client"; import React, { useState } from "react"; -import { - createStream, - toBaseUnits, - toDurationSeconds, - getTokenAddress, - toSorobanErrorMessage +import { + createStream, + toBaseUnits, + toDurationSeconds, + getTokenAddress, + toSorobanErrorMessage } from "@/lib/soroban"; +import { hasValidPrecision, validateAmountInput } from "@/utils/amount"; import { toast } from "react-hot-toast"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { ArrowLeft } from "lucide-react"; import { useWallet } from "@/context/wallet-context"; +const TOKEN_DECIMALS = 7; + export default function CreateStreamPage() { const { status, session } = useWallet(); const router = useRouter(); @@ -33,6 +36,13 @@ export default function CreateStreamPage() { return; } + // Validate amount + const validationError = validateAmountInput(formData.amount, TOKEN_DECIMALS); + if (validationError) { + toast.error(validationError); + return; + } + setLoading(true); setTxState("signing"); @@ -126,13 +136,27 @@ export default function CreateStreamPage() { Total Amount setFormData({ ...formData, amount: e.target.value })} + onChange={(e) => { + const newValue = e.target.value; + // Only allow valid number characters and check precision + if (newValue === '' || /^\d*\.?\d*$/.test(newValue)) { + if (hasValidPrecision(newValue, TOKEN_DECIMALS)) { + setFormData({ ...formData, amount: newValue }); + } + } + }} required /> + {formData.amount && !validateAmountInput(formData.amount, TOKEN_DECIMALS) && ( +

+ Amount must be greater than 0 with max {TOKEN_DECIMALS} decimals +

+ )} diff --git a/frontend/src/app/streams/streams/[streamId]/page.tsx b/frontend/src/app/streams/streams/[streamId]/page.tsx index 02d36d9..68e78a5 100644 --- a/frontend/src/app/streams/streams/[streamId]/page.tsx +++ b/frontend/src/app/streams/streams/[streamId]/page.tsx @@ -14,9 +14,10 @@ import { toSorobanErrorMessage, } from "@/lib/soroban"; import { shortenPublicKey } from "@/lib/wallet"; +import { formatAmount } from "@/utils/amount"; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/v1"; -const TOKEN_DECIMALS = 1e7; +const TOKEN_DECIMALS = 7; interface StreamDetailsPageProps { params: { @@ -27,7 +28,7 @@ interface StreamDetailsPageProps { function toDisplayAmount(baseUnits: string): number { const parsed = Number(baseUnits); if (!Number.isFinite(parsed)) return 0; - return parsed / TOKEN_DECIMALS; + return Number(formatAmount(BigInt(parsed), TOKEN_DECIMALS)); } function formatUnixTimestamp(timestamp: number): string { diff --git a/frontend/src/components/NotificationDropdown.tsx b/frontend/src/components/NotificationDropdown.tsx index 9c2c722..166c48f 100644 --- a/frontend/src/components/NotificationDropdown.tsx +++ b/frontend/src/components/NotificationDropdown.tsx @@ -1,9 +1,8 @@ "use client"; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useStreamEvents } from '@/hooks/useStreamEvents'; -import { formatAmount } from '@/lib/amount'; +import { formatAmount } from '@/utils/amount'; import { Button } from './ui/Button'; -import { fetchUserEvents } from '@/lib/dashboard'; interface NotificationDropdownProps { publicKey: string; @@ -21,7 +20,6 @@ interface NotificationItem { export const NotificationDropdown: React.FC = ({ publicKey }) => { const [isOpen, setIsOpen] = useState(false); const [notifications, setNotifications] = useState([]); - const [isLoading, setIsLoading] = useState(false); const [unreadCount, setUnreadCount] = useState(0); // Subscribe to live stream events for the user @@ -57,35 +55,29 @@ export const NotificationDropdown: React.FC = ({ publ } }, []); - const loadEvents = useCallback(async () => { - if (!publicKey) return; - setIsLoading(true); - try { - const data = await fetchUserEvents(publicKey); - const initialNotifications: NotificationItem[] = data.slice(0, 20).map(event => ({ - id: `init-${event.id}`, - streamId: event.streamId, - type: event.eventType.toLowerCase(), - message: formatEventMessage({ - type: event.eventType.toLowerCase(), - data: { streamId: event.streamId, amount: event.amount } as Record - }), - timestamp: event.timestamp * 1000, - read: true - })); - setNotifications(initialNotifications); - } catch (error) { - console.error(error); - } finally { - setIsLoading(false); - } - }, [publicKey, formatEventMessage]); - + // Process live events into notifications useEffect(() => { - if (publicKey) { - loadEvents(); + if (streamEvents.length === 0) return; + + const newNotifications = streamEvents.map(event => ({ + id: `${event.type}-${event.timestamp}`, + streamId: (event.data as { streamId?: number })?.streamId || 0, + type: event.type as NotificationItem['type'], + message: formatEventMessage(event), + timestamp: event.timestamp, + read: false + })); + + if (newNotifications.length > 0) { + setNotifications(prev => { + const combined = [...newNotifications, ...prev]; + const unique = combined.filter((notif, index, self) => + index === self.findIndex(n => n.id === notif.id) + ); + return unique.slice(0, 20); + }); } - }, [publicKey, loadEvents]); + }, [streamEvents, formatEventMessage]); // Handle incoming SSE events useEffect(() => { @@ -136,8 +128,8 @@ export const NotificationDropdown: React.FC = ({ publ {unreadCount > 9 ? '9+' : unreadCount} )} - {!connected && ( - + {!connected && unreadCount === 0 && ( + )} @@ -160,11 +152,7 @@ export const NotificationDropdown: React.FC = ({ publ
- {isLoading ? ( -
- Loading notifications... -
- ) : notifications.length > 0 ? ( + {notifications.length > 0 ? (
{notifications.map((notification) => (
= ({ return () => window.removeEventListener("keydown", handleEscape); }, [onClose, isSubmitting]); + // Token decimals - using 7 as default (Stellar standard) + const TOKEN_DECIMALS = 7; + const validate = (): boolean => { - const parsed = parseFloat(amount); - if (!amount.trim() || isNaN(parsed) || parsed <= 0) { - setError("Please enter a valid positive amount."); + const validationError = validateAmountInput(amount, TOKEN_DECIMALS); + if (validationError) { + setError(validationError); return false; } if (!hasValidPrecision(amount, 7)) { @@ -117,13 +120,18 @@ export const TopUpModal: React.FC = ({ { - setAmount(e.target.value); - if (error) setError(null); + const newValue = e.target.value; + // Only allow valid number characters and check precision + if (newValue === '' || /^\d*\.?\d*$/.test(newValue)) { + if (hasValidPrecision(newValue, TOKEN_DECIMALS)) { + setAmount(newValue); + if (error) setError(null); + } + } }} onKeyDown={(e) => { if (e.key === "Enter") void handleConfirm(); diff --git a/frontend/src/utils/amount.ts b/frontend/src/utils/amount.ts new file mode 100644 index 0000000..f82b758 --- /dev/null +++ b/frontend/src/utils/amount.ts @@ -0,0 +1,312 @@ +/** + * Convert raw on-chain amount (smallest unit) to human-readable string + * @param raw - Raw amount as bigint (i128 from chain) + * @param decimals - Number of decimal places for the token + * @returns Formatted string e.g., "10.5000000" + */ +export function formatAmount(raw: bigint, decimals: number): string { + if (raw === 0n) return "0"; + if (decimals === 0) return raw.toString(); + + const factor = 10n ** BigInt(decimals); + const integerPart = raw / factor; + const fractionalPart = raw % factor; + + const fractionalStr = fractionalPart.toString().padStart(decimals, "0"); + const trimmedFractional = fractionalStr.replace(/0+$/, ""); + + if (!trimmedFractional) return integerPart.toString(); + return `${integerPart}.${trimmedFractional}`; +} + +/** + * Alias for formatAmount - convert raw on-chain amount to human-readable string + * @deprecated Use formatAmount instead + */ +export function fromStroops(amount: bigint, decimals: number): string { + return formatAmount(amount, decimals); +} + +/** + * Convert human-readable amount to raw on-chain amount (smallest unit) + * @param display - Display string (e.g., "1.234") + * @param decimals - Number of decimal places for the token + * @returns Raw amount as bigint + */ +export function parseAmount(display: string, decimals: number): bigint { + const trimmed = display.trim(); + if (!trimmed) return 0n; + + // Validate input is a valid number format + if (!/^\d*\.?\d*$/.test(trimmed)) return 0n; + + const parts = trimmed.split("."); + const integerPart = parts[0] || "0"; + let fractionalPart = parts[1] || ""; + + if (fractionalPart.length > decimals) { + fractionalPart = fractionalPart.slice(0, decimals); + } else if (fractionalPart.length < decimals) { + fractionalPart = fractionalPart.padEnd(decimals, "0"); + } + + const factor = 10n ** BigInt(decimals); + return BigInt(integerPart) * factor + BigInt(fractionalPart); +} + +/** + * Alias for parseAmount - convert human-readable to raw on-chain amount + * @deprecated Use parseAmount instead + */ +export function toStroops(amount: string, decimals: number): bigint { + return parseAmount(amount, decimals); +} + +/** + * Format rate per second to human-readable string showing both per-second and per-day + * @param ratePerSec - Rate per second as bigint + * @param decimals - Number of decimal places for the token + * @param symbol - Token symbol (optional) + * @returns Formatted rate string e.g., "0.0001 USDC/sec (8.64 USDC/day)" + */ +export function formatRate( + ratePerSec: bigint, + decimals: number, + symbol = "" +): string { + if (ratePerSec === 0n) return symbol ? `0 ${symbol}/sec` : "0/sec"; + + const perSecond = formatAmount(ratePerSec, decimals); + const perDay = formatAmount(ratePerSec * 86400n, decimals); // 86400 seconds in a day + + const symbolStr = symbol ? ` ${symbol}` : ""; + return `${perSecond}${symbolStr}/sec (${perDay}${symbolStr}/day)`; +} + +/** + * Format stream rate as human-readable string + * @deprecated Use formatRate instead + */ +export function formatStreamRate( + ratePerSecond: bigint, + decimals: number, + tokenSymbol: string = "USDC" +): string { + return formatRate(ratePerSecond, decimals, tokenSymbol); +} + +/** + * Check if input string has valid precision for the given decimals + * @param input - Input string to validate + * @param decimals - Maximum allowed decimal places + * @returns True if valid precision + */ +export function hasValidPrecision(input: string, decimals: number): boolean { + if (!input || input.trim() === "") return true; // Empty is valid (will be parsed as 0) + + const cleanInput = input.trim(); + + // Check if it's a valid number format (digits with optional single decimal point) + if (!/^\d*\.?\d*$/.test(cleanInput)) return false; + + // Check for negative sign (not allowed for amounts) + if (cleanInput.startsWith("-")) return false; + + if (cleanInput.includes(".")) { + const fractionalPart = cleanInput.split(".")[1]; + return fractionalPart ? fractionalPart.length <= decimals : true; + } + + return true; +} + +/** + * Validate amount input and return error message if invalid + * @param input - Input string to validate + * @param decimals - Maximum allowed decimal places + * @returns Error message or null if valid + */ +export function validateAmountInput( + input: string, + decimals: number +): string | null { + if (!input || input.trim() === "") { + return "Amount is required"; + } + + const cleanInput = input.trim(); + + // Check for valid number format + if (!/^\d*\.?\d*$/.test(cleanInput)) { + return "Please enter a valid number"; + } + + // Check precision + if (!hasValidPrecision(cleanInput, decimals)) { + return `Amount cannot have more than ${decimals} decimal places`; + } + + // Check for positive value + const numericValue = parseFloat(cleanInput); + if (isNaN(numericValue) || numericValue <= 0) { + return "Amount must be greater than 0"; + } + + return null; +} + +/** + * Cache for token decimals to avoid repeated contract calls + */ +const tokenDecimalsCache = new Map(); + +// Default decimals for known tokens (Stellar uses 7 for XLM, 6 or 7 for most tokens) +const DEFAULT_TOKEN_DECIMALS: Record = { + XLM: 7, + USDC: 7, + EURC: 7, + FLOW: 7, +}; + +/** + * Get cached token decimals for a given token address + * @param tokenAddress - Token contract address + * @returns Cached decimals or undefined if not cached + */ +export function getCachedTokenDecimals(tokenAddress: string): number | undefined { + return tokenDecimalsCache.get(tokenAddress); +} + +/** + * Set token decimals in cache + * @param tokenAddress - Token contract address + * @param decimals - Token decimal places + */ +export function setCachedTokenDecimals( + tokenAddress: string, + decimals: number +): void { + tokenDecimalsCache.set(tokenAddress, decimals); +} + +/** + * Clear token decimals cache + */ +export function clearTokenDecimalsCache(): void { + tokenDecimalsCache.clear(); +} + +/** + * Get default decimals for known token symbols + * @param symbol - Token symbol (e.g., "USDC", "XLM") + * @returns Default decimals or 7 if unknown + */ +export function getDefaultTokenDecimals(symbol: string): number { + return DEFAULT_TOKEN_DECIMALS[symbol.toUpperCase()] ?? 7; +} + +// RPC configuration for fetching token decimals +const SOROBAN_RPC_URL = + process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org"; +const NETWORK_PASSPHRASE = + process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + +/** + * Fetch token decimals from the Soroban contract + * Fetches once and caches per token address + * @param tokenAddress - Token contract address + * @returns Promise resolving to token decimals + */ +export async function fetchTokenDecimals(tokenAddress: string): Promise { + // Check cache first + const cached = getCachedTokenDecimals(tokenAddress); + if (cached !== undefined) { + return cached; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sdk: any = await import("@stellar/stellar-sdk"); + const { Contract, TransactionBuilder, BASE_FEE } = sdk; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rpc: any = sdk.rpc ?? sdk.SorobanRpc; + + // Use a dummy account for simulation (just need to read contract data) + const dummyKeypair = sdk.Keypair.random(); + const server = new rpc.Server(SOROBAN_RPC_URL, { allowHttp: false }); + + // Build a transaction to call the 'decimals' function + const account = await server.getAccount(dummyKeypair.publicKey()); + const tokenContract = new Contract(tokenAddress); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(tokenContract.call("decimals")) + .setTimeout(30) + .build(); + + const simResult = await server.simulateTransaction(tx); + + if (rpc.Api?.isSimulationError?.(simResult) ?? simResult?.error) { + console.warn(`Failed to fetch decimals for ${tokenAddress}:`, simResult.error); + // Cache default to avoid repeated failed calls + setCachedTokenDecimals(tokenAddress, 7); + return 7; + } + + const rawResult = simResult?.result?.retval; + if (!rawResult) { + console.warn(`No decimals returned for ${tokenAddress}`); + setCachedTokenDecimals(tokenAddress, 7); + return 7; + } + + const nativeValue = sdk.scValToNative(rawResult); + let decimals: number; + + if (typeof nativeValue === "number") { + decimals = nativeValue; + } else if (typeof nativeValue === "bigint") { + decimals = Number(nativeValue); + } else if (typeof nativeValue === "string") { + decimals = parseInt(nativeValue, 10); + } else { + decimals = 7; + } + + // Cache the result + setCachedTokenDecimals(tokenAddress, decimals); + return decimals; + } catch (error) { + console.error(`Error fetching token decimals for ${tokenAddress}:`, error); + // Cache default to avoid repeated failed calls + setCachedTokenDecimals(tokenAddress, 7); + return 7; + } +} + +/** + * React hook compatible function to get token decimals + * Returns cached value immediately if available, otherwise fetches and caches + * @param tokenAddress - Token contract address + * @param callback - Optional callback when decimals are fetched + * @returns Current decimals (cached or default 7) + */ +export function getTokenDecimalsSync( + tokenAddress: string, + callback?: (decimals: number) => void +): number { + const cached = getCachedTokenDecimals(tokenAddress); + if (cached !== undefined) { + return cached; + } + + // Trigger async fetch if not cached + if (callback) { + fetchTokenDecimals(tokenAddress).then(callback).catch(() => callback(7)); + } + + return 7; // Return default while fetching +} From 540da4828fc62f665d8e2d4e26e5f49fd0c91bec Mon Sep 17 00:00:00 2001 From: Lynndabel Date: Tue, 28 Apr 2026 18:48:41 +0100 Subject: [PATCH 02/12] Add settings page with theme persistence, wallet info, and about section --- frontend/src/app/layout.tsx | 21 ++- frontend/src/app/settings/page.tsx | 113 +++++++++++++++ frontend/src/components/ModeToggle.tsx | 44 ++++-- frontend/src/hooks/useSettings.ts | 187 +++++++++++++++++++++++++ 4 files changed, 356 insertions(+), 9 deletions(-) create mode 100644 frontend/src/hooks/useSettings.ts diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 3bdf02b..ae4149f 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -34,10 +34,29 @@ export default function RootLayout({ }>) { return ( + +