diff --git a/backend/docs/DEPRECATION_POLICY.md b/backend/docs/DEPRECATION_POLICY.md index 6bcc0bf1..21531728 100644 --- a/backend/docs/DEPRECATION_POLICY.md +++ b/backend/docs/DEPRECATION_POLICY.md @@ -148,9 +148,10 @@ X-API-Migration-Path: /v1/streams - `/streams` → `/v1/streams` - `/events` → `/v1/events` -**Status:** Deprecated (as of 2024-02-21) +**Status:** Removed (deprecated 2024-02-21, sunset 2024-12-31, handlers deleted 2026-06-30) -**Sunset Date:** 2024-12-31 +These routes no longer exist in the codebase. Clients still calling the unversioned +paths will receive a 404. Update all callers to use the `/v1/` prefix. **Migration:** ```javascript diff --git a/backend/src/app.ts b/backend/src/app.ts index 582ccc02..5229ffbc 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -105,35 +105,6 @@ app.use((req: Request, res: Response, next: NextFunction) => { return next(); // Not versioned, continue to deprecated handlers }); -// Legacy routes (deprecated - redirect to v1) -// These will be removed in a future version -// Only match unversioned requests -app.use('/streams', (req: Request, res: Response, next) => { - res.status(410).json({ - error: 'Deprecated endpoint', - message: 'This endpoint has been deprecated. Please use /v1/streams instead.', - deprecated: true, - migration: { - old: '/streams', - new: '/v1/streams', - }, - sunsetDate: '2024-12-31', - }); -}); - -app.use('/events', (req: Request, res: Response, next) => { - res.status(410).json({ - error: 'Deprecated endpoint', - message: 'This endpoint has been deprecated. Please use /v1/events instead.', - deprecated: true, - migration: { - old: '/events', - new: '/v1/events', - }, - sunsetDate: '2024-12-31', - }); -}); - // Health check routes app.use('/health', healthRoutes); diff --git a/contracts/stream_contract/src/lib.rs b/contracts/stream_contract/src/lib.rs index 23c19377..d0d2dd5d 100644 --- a/contracts/stream_contract/src/lib.rs +++ b/contracts/stream_contract/src/lib.rs @@ -342,12 +342,12 @@ impl StreamContract { let elapsed = effective_now.saturating_sub(stream.last_update_time); - // Use checked_sub for deposited - withdrawn calculation - // Underflow (withdrawn > deposited) falls back to 0. + // Clamp to 0: withdrawn_amount should never exceed deposited_amount in + // normal flow, but guard defensively so the function never returns negative. let remaining = stream .deposited_amount - .checked_sub(stream.withdrawn_amount) - .unwrap_or_default(); + .saturating_sub(stream.withdrawn_amount) + .max(0); // Use checked_mul to prevent overflow when multiplying rate * elapsed. // If overflow would occur, cap at the remaining balance. diff --git a/contracts/stream_contract/src/test.rs b/contracts/stream_contract/src/test.rs index 44b0465e..9c20d170 100644 --- a/contracts/stream_contract/src/test.rs +++ b/contracts/stream_contract/src/test.rs @@ -1042,6 +1042,36 @@ fn test_claimable_max_i128_rate_overflow() { assert_eq!(withdrawn, 1_000); } +// ─── #795 calculate_claimable underflow guard ───────────────────────────────── + +#[test] +fn test_calculate_claimable_underflow_returns_zero() { + let env = Env::default(); + env.mock_all_auths(); + + let (token, _) = create_token(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + mint(&env, &token, &sender, 1_000); + + let client = create_contract(&env); + let stream_id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000); + + // Forcibly set withdrawn_amount > deposited_amount to exercise the underflow guard. + let mut stream = client.get_stream(&stream_id).unwrap(); + stream.withdrawn_amount = stream.deposited_amount + 1; + env.as_contract(&client.address, || { + env.storage() + .persistent() + .set(&types::DataKey::Stream(stream_id), &stream); + }); + + // calculate_claimable uses checked_sub(...).unwrap_or_default(), so the + // underflow must return 0 rather than panicking or wrapping. + let claimable = client.get_claimable_amount(&stream_id).unwrap(); + assert_eq!(claimable, 0); +} + // ─── #232 create_stream edge cases ─────────────────────────────────────────── #[test] diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 05e726d1..6f979563 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -13,6 +13,15 @@ const eslintConfig = defineConfig([ "build/**", "next-env.d.ts", ]), + { + // Forbid raw console.* calls outside the logger module. + // Use src/lib/logger.ts instead. + files: ["src/**/*.{ts,tsx}"], + ignores: ["src/lib/logger.ts"], + rules: { + "no-console": "error", + }, + }, ]); export default eslintConfig; diff --git a/frontend/src/app/activity/activity-content.tsx b/frontend/src/app/activity/activity-content.tsx index c65f0832..eec09471 100644 --- a/frontend/src/app/activity/activity-content.tsx +++ b/frontend/src/app/activity/activity-content.tsx @@ -8,11 +8,11 @@ import { Button } from "@/components/ui/Button"; import { Loader2, Download } from "lucide-react"; import { formatAmount } from "@/lib/amount"; import { downloadCSV } from "@/utils/csvExport"; +import { getApiBaseUrl } from "@/lib/api/_shared"; +import { logger } from "@/lib/logger"; const PAGE_SIZE = 10; -const API_BASE_URL = ( - process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001" -).replace(/\/+$/, ""); +const API_BASE_URL = getApiBaseUrl(); const TABS = [ { id: "ALL", label: "All" }, @@ -62,7 +62,7 @@ export default function ActivityContent() { } } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; - console.error("Failed to fetch activity:", error); + logger.error("Failed to fetch activity:", error); if (!append) setEvents([]); setHasMore(false); } finally { diff --git a/frontend/src/app/settings/settings-content.tsx b/frontend/src/app/settings/settings-content.tsx index 4648d14b..bb50750a 100644 --- a/frontend/src/app/settings/settings-content.tsx +++ b/frontend/src/app/settings/settings-content.tsx @@ -8,6 +8,7 @@ import { useRouter } from "next/navigation"; import Link from "next/link"; import { formatNetwork } from "@/lib/wallet"; import toast from "react-hot-toast"; +import { getApiBaseUrl } from "@/lib/api/_shared"; type DisplayCurrency = "USD" | "XLM" | "USDC"; type AmountFormat = "full" | "compact"; @@ -15,7 +16,7 @@ type DecimalPlaces = 2 | 4 | 7; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "1.0.0"; const CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_STREAMING_CONTRACT || "CDV4K...7ZQY"; -const INDEXER_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/v1"; +const INDEXER_URL = `${getApiBaseUrl()}/v1`; export default function SettingsContent() { const router = useRouter(); diff --git a/frontend/src/app/streams/[id]/stream-details-content.tsx b/frontend/src/app/streams/[id]/stream-details-content.tsx index 3a7565ad..1c8cf265 100644 --- a/frontend/src/app/streams/[id]/stream-details-content.tsx +++ b/frontend/src/app/streams/[id]/stream-details-content.tsx @@ -2,6 +2,8 @@ import { useEffect, useState, useCallback, useMemo } from "react"; import Link from "next/link"; +import { getApiBaseUrl } from "@/lib/api/_shared"; +import { logger } from "@/lib/logger"; import { ArrowLeft, Pause, Play, X, Plus, Download, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/Button"; import toast from "react-hot-toast"; @@ -42,7 +44,7 @@ interface StreamDetail { updatedAt: string; } -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/v1"; +const API_BASE_URL = `${getApiBaseUrl()}/v1`; const EVENTS_PER_PAGE = 10; const TOKEN_SYMBOLS: Record = { @@ -116,7 +118,7 @@ export default function StreamDetailsContent({ streamId }: { streamId: string }) } } catch (err) { if (err instanceof Error && err.name === "AbortError") return; - console.error("Failed to fetch events:", err); + logger.error("Failed to fetch events:", err); } }, [streamId]); diff --git a/frontend/src/app/streams/create/create-stream-content.tsx b/frontend/src/app/streams/create/create-stream-content.tsx index 4a116380..ce1e9ff5 100644 --- a/frontend/src/app/streams/create/create-stream-content.tsx +++ b/frontend/src/app/streams/create/create-stream-content.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState } from "react"; +import { logger } from "@/lib/logger"; import { createStream, toBaseUnits, @@ -67,7 +68,7 @@ export default function CreateStreamContent() { }, 2000); } } catch (error) { - console.error("Stream creation failed:", error); + logger.error("Stream creation failed:", error); toast.error(toSorobanErrorMessage(error)); } finally { setLoading(false); diff --git a/frontend/src/app/streams/streams/[streamId]/page.tsx b/frontend/src/app/streams/streams/[streamId]/page.tsx index abc18aea..c602f843 100644 --- a/frontend/src/app/streams/streams/[streamId]/page.tsx +++ b/frontend/src/app/streams/streams/[streamId]/page.tsx @@ -15,8 +15,9 @@ import { } from "@/lib/soroban"; import { shortenPublicKey } from "@/lib/wallet"; import { formatAmount } from "@/utils/amount"; +import { getApiBaseUrl } from "@/lib/api/_shared"; -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/v1"; +const API_BASE_URL = `${getApiBaseUrl()}/v1`; const TOKEN_DECIMALS = 7; interface StreamDetailsPageProps { diff --git a/frontend/src/components/TransactionTracker.tsx b/frontend/src/components/TransactionTracker.tsx index f1157981..cf88944a 100644 --- a/frontend/src/components/TransactionTracker.tsx +++ b/frontend/src/components/TransactionTracker.tsx @@ -5,6 +5,8 @@ import { Loader2, CheckCircle, XCircle, ExternalLink, RefreshCw, Clock, Ban, Fil import toast from "react-hot-toast"; import type { BackendStream } from "@/lib/api-types"; import { formatAmount } from "@/utils/amount"; +import { getApiBaseUrl } from "@/lib/api/_shared"; +import { logger } from "@/lib/logger"; /** * TransactionTracker - Shared component for tracking on-chain transaction lifecycle @@ -56,7 +58,7 @@ interface TransactionTrackerProps { const STELLAR_EXPERT_BASE = process.env.NEXT_PUBLIC_STELLAR_EXPERT_URL || "https://stellar.expert/explorer/testnet/tx"; -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/v1"; +const API_BASE_URL = `${getApiBaseUrl()}/v1`; const POLL_INTERVAL = 3000; // 3 seconds as per requirements const MAX_POLL_ATTEMPTS = 20; // Max 1 minute of polling @@ -94,7 +96,7 @@ export default function TransactionTracker({ .then(data => { if (data) setPreviousStreamData(data); }) - .catch(console.error); + .catch(logger.error); } }, [status, streamId, previousStreamData]); @@ -132,7 +134,7 @@ export default function TransactionTracker({ } } catch (err) { if (!cancelled) { - console.error("Polling error:", err); + logger.error("Polling error:", err); } } diff --git a/frontend/src/components/stream-creation/StreamCreationWizard.tsx b/frontend/src/components/stream-creation/StreamCreationWizard.tsx index 1d620e9f..38298826 100644 --- a/frontend/src/components/stream-creation/StreamCreationWizard.tsx +++ b/frontend/src/components/stream-creation/StreamCreationWizard.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { hasValidPrecision } from "@/lib/amount"; import { useModalDialog } from "@/hooks/useModalDialog"; +import { logger } from "@/lib/logger"; import { Stepper } from "../ui/Stepper"; import { Button } from "../ui/Button"; import { RecipientStep } from "./RecipientStep"; @@ -361,7 +362,7 @@ export const StreamCreationWizard: React.FC = ({ return; } } catch (e) { - console.warn("Polling error:", e); + logger.warn("Polling error:", e); } await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)); } @@ -384,7 +385,7 @@ export const StreamCreationWizard: React.FC = ({ await startPolling(formData.recipient); } catch (error) { - console.error("Failed to create stream:", error); + logger.error("Failed to create stream:", error); setIsSubmitting(false); } } else { diff --git a/frontend/src/hooks/useIncomingStreams.ts b/frontend/src/hooks/useIncomingStreams.ts index dd10e078..6509a84b 100644 --- a/frontend/src/hooks/useIncomingStreams.ts +++ b/frontend/src/hooks/useIncomingStreams.ts @@ -6,6 +6,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { fetchIncomingStreams, type IncomingStreamRecord } from "@/lib/api/streams"; +import { logger } from "@/lib/logger"; import { withdrawFromStream, type SorobanResult, @@ -143,7 +144,7 @@ async function pollIndexerForWithdraw( return; } } catch (err) { - console.warn("Error polling indexer for withdraw:", err); + logger.warn("Error polling indexer for withdraw:", err); } delay *= 2; } diff --git a/frontend/src/lib/dashboard.ts b/frontend/src/lib/dashboard.ts index cf5b2cdc..6930f5e5 100644 --- a/frontend/src/lib/dashboard.ts +++ b/frontend/src/lib/dashboard.ts @@ -1,6 +1,7 @@ import type { BackendStream } from "./api-types"; import { getStreamsEndpointCandidates, toTokenAmount } from "./api/_shared"; import { TOKEN_ADDRESSES } from "./soroban"; +import { logger } from "./logger"; export interface ActivityItem { id: string; @@ -194,7 +195,7 @@ export async function fetchDashboardData(publicKey: string): Promise { + if (isDev) console.debug(...args); // eslint-disable-line no-console + }, + info: (...args: unknown[]) => { + if (isDev) console.info(...args); // eslint-disable-line no-console + }, + warn: (...args: unknown[]) => { + if (isDev) console.warn(...args); // eslint-disable-line no-console + }, + // errors always surface, even in production + error: (...args: unknown[]) => { + console.error(...args); // eslint-disable-line no-console + }, +}; diff --git a/frontend/src/lib/soroban.ts b/frontend/src/lib/soroban.ts index a3911839..742f4e39 100644 --- a/frontend/src/lib/soroban.ts +++ b/frontend/src/lib/soroban.ts @@ -1,4 +1,5 @@ import type { WalletSession } from "@/lib/wallet"; +import { logger } from "@/lib/logger"; const CONTRACT_ID = process.env.NEXT_PUBLIC_STREAM_CONTRACT_ID ?? "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4"; @@ -182,7 +183,7 @@ function mockTxHash(): string { } async function mockCall(label: string): Promise { - console.info(`[soroban:mock] ${label}`); + logger.info(`[soroban:mock] ${label}`); await wait(MOCK_DELAY_MS); return { success: true, txHash: mockTxHash() }; } diff --git a/frontend/src/utils/amount.ts b/frontend/src/utils/amount.ts index 9745ffa1..73675ba4 100644 --- a/frontend/src/utils/amount.ts +++ b/frontend/src/utils/amount.ts @@ -221,6 +221,8 @@ export function getDefaultTokenDecimals(symbol: string): number { return DEFAULT_TOKEN_DECIMALS[symbol.toUpperCase()] ?? 7; } +import { logger } from "@/lib/logger"; + // RPC configuration for fetching token decimals const SOROBAN_RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org"; @@ -266,7 +268,7 @@ export async function fetchTokenDecimals(tokenAddress: string): Promise const simResult = await server.simulateTransaction(tx); if (rpc.Api?.isSimulationError?.(simResult) ?? simResult?.error) { - console.warn(`Failed to fetch decimals for ${tokenAddress}:`, simResult.error); + logger.warn(`Failed to fetch decimals for ${tokenAddress}:`, simResult.error); // Cache default to avoid repeated failed calls setCachedTokenDecimals(tokenAddress, 7); return 7; @@ -274,7 +276,7 @@ export async function fetchTokenDecimals(tokenAddress: string): Promise const rawResult = simResult?.result?.retval; if (!rawResult) { - console.warn(`No decimals returned for ${tokenAddress}`); + logger.warn(`No decimals returned for ${tokenAddress}`); setCachedTokenDecimals(tokenAddress, 7); return 7; } @@ -296,7 +298,7 @@ export async function fetchTokenDecimals(tokenAddress: string): Promise setCachedTokenDecimals(tokenAddress, decimals); return decimals; } catch (error) { - console.error(`Error fetching token decimals for ${tokenAddress}:`, error); + logger.error(`Error fetching token decimals for ${tokenAddress}:`, error); // Cache default to avoid repeated failed calls setCachedTokenDecimals(tokenAddress, 7); return 7;