From 1b0e1dadb55d9c7525484435adcddf4b8d907f6d Mon Sep 17 00:00:00 2001
From: BaseDev-Eth
Date: Mon, 31 Aug 2026 07:47:34 +0100
Subject: [PATCH] fix(#387,#400,#402,#398): invert demo-history ternary,
validate stream id, graceful withdrawable fallback, cap RateTicker at endTime
- #387: flip fetchTransactionHistory ternary so demo data returns only when
no publicKey is connected, not for real wallets
- #400: validate URL segment against /^\d+$/ before BigInt() to prevent
raw SyntaxError surfacing to the user
- #402: load getStreamInfo first, then fetch getWithdrawable separately with
independent error handling so a withdrawable failure doesn't block the
entire detail page (both initial load and background refresh)
- #398: add endTime prop to RateTicker and cap elapsed seconds so the live
counter freezes at the stream's end instead of ticking past the contract
balance
---
app/stream/[id]/page.tsx | 37 ++++++++++++++++++++------------
components/stream/RateTicker.tsx | 22 +++++++++++++++----
lib/indexer.ts | 2 +-
3 files changed, 42 insertions(+), 19 deletions(-)
diff --git a/app/stream/[id]/page.tsx b/app/stream/[id]/page.tsx
index 29917ba..cff11c9 100644
--- a/app/stream/[id]/page.tsx
+++ b/app/stream/[id]/page.tsx
@@ -77,19 +77,27 @@ export default function StreamPage() {
setLoading(true);
setError(null);
try {
+ if (!/^\d+$/.test(id)) {
+ if (isCurrent()) setError('Invalid stream ID.');
+ return;
+ }
+
const addr = await getStreamAddress(publicKey, BigInt(id));
if (!isCurrent()) return;
if (!addr) { setError('Stream not found.'); return; }
- const [streamInfo, wAmt] = await Promise.all([
- getStreamInfo(publicKey, addr),
- getWithdrawable(publicKey, addr),
- ]);
-
+ const streamInfo = await getStreamInfo(publicKey, addr);
if (!isCurrent()) return;
+
setStreamAddress(addr);
setInfo(streamInfo);
- setWithdrawable(wAmt);
+
+ try {
+ const wAmt = await getWithdrawable(publicKey, addr);
+ if (isCurrent()) setWithdrawable(wAmt);
+ } catch {
+ if (isCurrent()) setWithdrawable(0n);
+ }
} catch (e) {
if (!isCurrent()) return;
setError(e instanceof Error ? e.message : 'Failed to load stream.');
@@ -109,17 +117,17 @@ export default function StreamPage() {
const addr = streamAddress;
const t = setInterval(async () => {
try {
- const [streamInfo, wAmt] = await Promise.all([
- getStreamInfo(publicKey, addr),
- getWithdrawable(publicKey, addr),
- ]);
- if (mounted.current) {
- setInfo(streamInfo);
- setWithdrawable(wAmt);
- }
+ const streamInfo = await getStreamInfo(publicKey, addr);
+ if (mounted.current) setInfo(streamInfo);
} catch {
/* keep last-good data */
}
+ try {
+ const wAmt = await getWithdrawable(publicKey, addr);
+ if (mounted.current) setWithdrawable(wAmt);
+ } catch {
+ /* keep last-good withdrawable */
+ }
}, STREAM_REFRESH_MS);
return () => clearInterval(t);
}, [publicKey, streamAddress]);
@@ -200,6 +208,7 @@ export default function StreamPage() {
{tokenSymbol}
diff --git a/components/stream/RateTicker.tsx b/components/stream/RateTicker.tsx
index 9992468..7355af7 100644
--- a/components/stream/RateTicker.tsx
+++ b/components/stream/RateTicker.tsx
@@ -10,13 +10,16 @@ interface RateTickerProps {
startBalance: bigint;
/** Decimal places to display (default: 7 for XLM) */
decimals?: number;
+ /** Unix timestamp when the stream ends (0 = open-ended). Ticker freezes past this. */
+ endTime?: number;
}
/**
* Live-updating balance counter.
* Increments every 100ms based on ratePerSecond without any contract calls.
+ * Freezes at endTime so the ticker doesn't overshoot the contract balance (#398).
*/
-export function RateTicker({ ratePerSecond, startBalance, decimals = 7 }: RateTickerProps) {
+export function RateTicker({ ratePerSecond, startBalance, decimals = 7, endTime = 0 }: RateTickerProps) {
const startRef = useRef<{ ts: number; balance: bigint }>({
ts: Date.now(),
balance: startBalance,
@@ -30,12 +33,23 @@ export function RateTicker({ ratePerSecond, startBalance, decimals = 7 }: RateTi
useEffect(() => {
const id = setInterval(() => {
- const elapsed = BigInt(Math.floor((Date.now() - startRef.current.ts) / 1000));
- const current = startRef.current.balance + elapsed * ratePerSecond;
+ const elapsedMs = Date.now() - startRef.current.ts;
+ let elapsedSec = BigInt(Math.floor(elapsedMs / 1000));
+
+ if (endTime > 0) {
+ const endMs = endTime * 1000;
+ const remainingMs = endMs - startRef.current.ts;
+ const remainingSec = BigInt(Math.max(0, Math.floor(remainingMs / 1000)));
+ if (elapsedSec > remainingSec) elapsedSec = remainingSec;
+ }
+
+ if (elapsedSec < 0n) elapsedSec = 0n;
+
+ const current = startRef.current.balance + elapsedSec * ratePerSecond;
setDisplay(fromStroops(current, decimals));
}, 100);
return () => clearInterval(id);
- }, [ratePerSecond, decimals]);
+ }, [ratePerSecond, decimals, endTime]);
return (
diff --git a/lib/indexer.ts b/lib/indexer.ts
index 5a49890..408ccd8 100644
--- a/lib/indexer.ts
+++ b/lib/indexer.ts
@@ -74,7 +74,7 @@ export async function fetchTransactionHistory(
return;
}
if (mock) {
- resolve(publicKey ? DEMO_TXS : []);
+ resolve(publicKey ? [] : DEMO_TXS);
} else {
reject(
new Error(