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
5 changes: 3 additions & 2 deletions backend/docs/DEPRECATION_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 0 additions & 29 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
8 changes: 4 additions & 4 deletions contracts/stream_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions contracts/stream_contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
9 changes: 9 additions & 0 deletions frontend/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
8 changes: 4 additions & 4 deletions frontend/src/app/activity/activity-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/app/settings/settings-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ 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";
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();
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/app/streams/[id]/stream-details-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -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]);

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/app/streams/create/create-stream-content.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import React, { useState } from "react";
import { logger } from "@/lib/logger";
import {
createStream,
toBaseUnits,
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/app/streams/streams/[streamId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/components/TransactionTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -94,7 +96,7 @@ export default function TransactionTracker({
.then(data => {
if (data) setPreviousStreamData(data);
})
.catch(console.error);
.catch(logger.error);
}
}, [status, streamId, previousStreamData]);

Expand Down Expand Up @@ -132,7 +134,7 @@ export default function TransactionTracker({
}
} catch (err) {
if (!cancelled) {
console.error("Polling error:", err);
logger.error("Polling error:", err);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -361,7 +362,7 @@ export const StreamCreationWizard: React.FC<StreamCreationWizardProps> = ({
return;
}
} catch (e) {
console.warn("Polling error:", e);
logger.warn("Polling error:", e);
}
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
}
Expand All @@ -384,7 +385,7 @@ export const StreamCreationWizard: React.FC<StreamCreationWizardProps> = ({
await startPolling(formData.recipient);

} catch (error) {
console.error("Failed to create stream:", error);
logger.error("Failed to create stream:", error);
setIsSubmitting(false);
}
} else {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/hooks/useIncomingStreams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/dashboard.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -194,7 +195,7 @@ export async function fetchDashboardData(publicKey: string): Promise<DashboardSn
incomingStreams,
};
} catch (error) {
console.error("Dashboard data fetch error:", error);
logger.error("Dashboard data fetch error:", error);
throw error;
}
}
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/lib/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const isDev = process.env.NODE_ENV !== "production";

export const logger = {
debug: (...args: unknown[]) => {
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
},
};
3 changes: 2 additions & 1 deletion frontend/src/lib/soroban.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -182,7 +183,7 @@ function mockTxHash(): string {
}

async function mockCall(label: string): Promise<SorobanResult> {
console.info(`[soroban:mock] ${label}`);
logger.info(`[soroban:mock] ${label}`);
await wait(MOCK_DELAY_MS);
return { success: true, txHash: mockTxHash() };
}
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/utils/amount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -266,15 +268,15 @@ export async function fetchTokenDecimals(tokenAddress: string): Promise<number>
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;
}

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;
}
Expand All @@ -296,7 +298,7 @@ export async function fetchTokenDecimals(tokenAddress: string): Promise<number>
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;
Expand Down
Loading