diff --git a/src/components/ContractEventFeed.test.tsx b/src/components/ContractEventFeed.test.tsx index 99432b7..d9c8bef 100644 --- a/src/components/ContractEventFeed.test.tsx +++ b/src/components/ContractEventFeed.test.tsx @@ -127,6 +127,48 @@ describe("ContractEventFeed", () => { expect(getEvents).toHaveBeenCalledTimes(callsAfterPause); }); + it("pauses polling while the screen is hidden and resumes when visible again (#533)", async () => { + const getEvents = vi.fn().mockResolvedValue({ data: [], error: null }); + vi.mocked(getClient).mockReturnValue({ + soroban: { getEvents }, + } as unknown as SorokitClient); + + let observerCallback: IntersectionObserverCallback | undefined; + vi.stubGlobal( + "IntersectionObserver", + class { + constructor(callback: IntersectionObserverCallback) { + observerCallback = callback; + } + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + }, + ); + + render(); + act(() => { vi.advanceTimersByTime(0); }); + await waitFor(() => expect(getEvents).toHaveBeenCalledTimes(1)); + + // Dashboard hides this screen (mount-once, keep-alive pattern). + act(() => { + observerCallback?.([{ isIntersecting: false } as IntersectionObserverEntry], {} as IntersectionObserver); + }); + + // Well past the poll interval while hidden — no new calls. + act(() => { vi.advanceTimersByTime(1500); }); + expect(getEvents).toHaveBeenCalledTimes(1); + + // Becomes visible again — polling resumes. + act(() => { + observerCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver); + }); + act(() => { vi.advanceTimersByTime(500) }); + await waitFor(() => expect(getEvents).toHaveBeenCalledTimes(2)); + + vi.unstubAllGlobals(); + }); + it("triggers a new load when contractId changes", async () => { const getEvents = vi.fn().mockResolvedValue({ data: [], error: null }); vi.mocked(getClient).mockReturnValue({ diff --git a/src/components/ContractEventFeed.tsx b/src/components/ContractEventFeed.tsx index 07a5f0c..97fa594 100644 --- a/src/components/ContractEventFeed.tsx +++ b/src/components/ContractEventFeed.tsx @@ -51,6 +51,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Badge } from "@/components/ui/Badge"; import { useSorokit } from "@/context/useSorokit"; +import { useIsVisible } from "@/hooks/useIsVisible"; import type { ContractEvent } from "@/lib/client"; import { cn, truncateAddress } from "@/lib/utils"; @@ -249,6 +250,7 @@ export function ContractEventFeed({ filterTypes ? new Set(filterTypes) : null, ); const intervalRef = useRef | null>(null); + const [containerRef, isVisible] = useIsVisible(); // IDs highlighted as newly-arrived. `prevEventIdsRef` is the baseline from // the previous successful load — `null` means no baseline yet, so the very @@ -332,7 +334,14 @@ export function ContractEventFeed({ }, [load]); useEffect(() => { - if (live && pollInterval > 0 && contractId.trim() !== "") { + // Dashboard keeps a visited screen mounted rather than unmounting it, + // to preserve in-progress state — see the comment in Dashboard.tsx. + // Gating on isVisible (in addition to the user-facing `live` toggle) + // stops this from polling in the background once its screen is no + // longer the active one (#533), without disturbing `live`'s own + // on/off semantics — resuming visibility restores whatever `live` was + // already set to. + if (live && isVisible && pollInterval > 0 && contractId.trim() !== "") { intervalRef.current = setInterval(() => { void load(); }, pollInterval); @@ -342,14 +351,16 @@ export function ContractEventFeed({ return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; - }, [live, pollInterval, load, contractId]); + }, [live, isVisible, pollInterval, load, contractId]); - // Tick the relative "Last updated" label once a second while polling is active. + // Tick the relative "Last updated" label once a second while polling is + // active and visible — ticking a hidden screen's clock wastes a timer for + // a label nobody can see. useEffect(() => { - if (!live || pollInterval <= 0) return; + if (!live || !isVisible || pollInterval <= 0) return; const tickId = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(tickId); - }, [live, pollInterval]); + }, [live, isVisible, pollInterval]); const typeCounts = useMemo(() => { const counts = new Map(); @@ -387,7 +398,10 @@ export function ContractEventFeed({ activeTypes ? activeTypes.has(type) : true; return ( -
+

diff --git a/src/components/FeeEstimator.test.tsx b/src/components/FeeEstimator.test.tsx index 594d38a..b790f42 100644 --- a/src/components/FeeEstimator.test.tsx +++ b/src/components/FeeEstimator.test.tsx @@ -238,6 +238,68 @@ describe("FeeEstimator", { timeout: 15000 }, () => { vi.useRealTimers(); }); + + it("pauses polling while hidden and resumes when visible again (#533)", async () => { + vi.useFakeTimers(); + const estimateFee = vi.fn().mockResolvedValue({ + data: { baseFee: "100", recommended: "500" }, + error: null, + }); + vi.mocked(getClient).mockReturnValue({ + transaction: { estimateFee }, + } as unknown as SorokitClient); + + let observerCallback: IntersectionObserverCallback | undefined; + const observe = vi.fn(); + const disconnect = vi.fn(); + vi.stubGlobal( + "IntersectionObserver", + class { + constructor(callback: IntersectionObserverCallback) { + observerCallback = callback; + } + observe = observe; + disconnect = disconnect; + unobserve = vi.fn(); + }, + ); + + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(estimateFee).toHaveBeenCalledTimes(1); + + // Simulate Dashboard hiding this screen (the ContractEventFeed-style + // "mount once, keep alive" pattern — see Dashboard.tsx). + act(() => { + observerCallback?.([{ isIntersecting: false } as IntersectionObserverEntry], {} as IntersectionObserver); + }); + + // Time passes while hidden — no new calls should fire. + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(estimateFee).toHaveBeenCalledTimes(1); + + // Becomes visible again — polling resumes. + act(() => { + observerCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(estimateFee).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(estimateFee).toHaveBeenCalledTimes(3); + + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); }); describe("FeeCell export", () => { diff --git a/src/components/FeeEstimator.tsx b/src/components/FeeEstimator.tsx index d232da1..956217f 100644 --- a/src/components/FeeEstimator.tsx +++ b/src/components/FeeEstimator.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react"; import { Badge } from "@/components/ui/Badge"; import { Tooltip } from "@/components/ui/Tooltip"; import { useSorokit } from "@/context/useSorokit"; +import { useIsVisible } from "@/hooks/useIsVisible"; import { cn, toXLM } from "@/lib/utils"; export interface FeeData { @@ -32,6 +33,7 @@ export function FeeEstimator({ const [fee, setFee] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [containerRef, isVisible] = useIsVisible(); const load = useCallback(async () => { if (!client) return; @@ -53,6 +55,13 @@ export function FeeEstimator({ }, [client, onFeeLoad]); useEffect(() => { + // Dashboard keeps a visited screen mounted (rather than unmounting it) + // to preserve in-progress state — see the comment in Dashboard.tsx. + // That means a screen navigated away from is still mounted, just + // hidden; without this check, a refreshInterval keeps firing network + // requests for a screen the user can no longer see (#533). + if (!isVisible) return; + const timerId = window.setTimeout(() => { void load(); }, 0); @@ -68,7 +77,7 @@ export function FeeEstimator({ return () => { window.clearTimeout(timerId); }; - }, [load, refreshInterval]); + }, [load, refreshInterval, isVisible]); const compactContent = fee ? `Base: ${fee.baseFee} stroops · Recommended: ${fee.recommended} stroops` @@ -76,6 +85,7 @@ export function FeeEstimator({ return (
{ + beforeEach(() => { + MockIntersectionObserver.instances = []; + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("starts visible (optimistic) before the observer fires", () => { + const { result } = renderHook(() => useIsVisible()); + const [, isVisible] = result.current; + expect(isVisible).toBe(true); + }); + + it("does nothing until the ref is attached to a node", () => { + renderHook(() => useIsVisible()); + // No node was ever assigned to ref.current, so observe() is never + // called and no observer instance should have been constructed with + // an observed node. + expect( + MockIntersectionObserver.instances.every((i) => i.observedNode === null), + ).toBe(true); + }); + + it("updates to false when the observed element is not intersecting", () => { + const node = document.createElement("div"); + const { result, rerender } = renderHook(() => { + const [ref, isVisible] = useIsVisible(); + // Attach the node on every render, the way a component's `ref={ref}` + // JSX prop would on mount. + ref.current = node; + return { ref, isVisible }; + }); + + // Manually drive the effect that calls observe() by attaching the node + // before the first effect run, then forcing the effect to have run via + // renderHook's act-wrapped initial render. + expect(MockIntersectionObserver.instances.length).toBe(1); + act(() => { + MockIntersectionObserver.instances[0]!.trigger(false); + }); + rerender(); + expect(result.current.isVisible).toBe(false); + }); + + it("updates back to true when the observed element becomes intersecting again", () => { + const node = document.createElement("div"); + const { result, rerender } = renderHook(() => { + const [ref, isVisible] = useIsVisible(); + ref.current = node; + return { ref, isVisible }; + }); + + act(() => { + MockIntersectionObserver.instances[0]!.trigger(false); + }); + rerender(); + expect(result.current.isVisible).toBe(false); + + act(() => { + MockIntersectionObserver.instances[0]!.trigger(true); + }); + rerender(); + expect(result.current.isVisible).toBe(true); + }); + + it("disconnects the observer on unmount", () => { + const node = document.createElement("div"); + const { unmount } = renderHook(() => { + const [ref] = useIsVisible(); + ref.current = node; + return ref; + }); + + expect(MockIntersectionObserver.instances.length).toBe(1); + unmount(); + expect(MockIntersectionObserver.instances[0]!.disconnected).toBe(true); + }); + + it("fails open (stays visible) when IntersectionObserver is unavailable", () => { + vi.unstubAllGlobals(); + vi.stubGlobal("IntersectionObserver", undefined); + + const node = document.createElement("div"); + const { result } = renderHook(() => { + const [ref, isVisible] = useIsVisible(); + ref.current = node; + return isVisible; + }); + + expect(result.current).toBe(true); + }); +}); diff --git a/src/hooks/useIsVisible.ts b/src/hooks/useIsVisible.ts new file mode 100644 index 0000000..e6ebbed --- /dev/null +++ b/src/hooks/useIsVisible.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Tracks whether the element `ref` is attached to currently has a laid-out, + * non-zero-area box — i.e. it isn't `display: none` (directly, or via a + * `hidden` ancestor) and isn't scrolled fully out of any clipping + * container. Backed by `IntersectionObserver`, so this is layout-driven, + * not viewport-scroll-driven: an element under `hidden` reports `false` + * immediately, with no scrolling involved. + * + * Built for `Dashboard`'s "mount once, keep alive" screens (see + * `Dashboard.tsx`): a screen that isn't the active tab is still mounted + * (its form state and subscriptions survive navigating away) but is + * wrapped in an element with the `hidden` attribute. A component that + * polls — `FeeEstimator`, `ContractEventFeed` — uses this to pause that + * polling while its screen isn't the one showing, instead of either + * polling forever in the background or requiring `Dashboard` to thread an + * `isActive` prop through every screen and every component that polls. + * + * Returns `true` before the first observer callback fires (optimistic — + * assume visible until proven otherwise) so a component doesn't skip its + * very first load while waiting on the initial IntersectionObserver + * entry, which can arrive a frame or two after mount. + */ +export function useIsVisible(): [React.RefObject, boolean] { + const ref = useRef(null); + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + const node = ref.current; + if (!node) return; + + // No IntersectionObserver (very old browser, or a non-DOM test + // environment that doesn't polyfill it) — fail open rather than + // silently never polling. + if (typeof IntersectionObserver === "undefined") return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry) setIsVisible(entry.isIntersecting); + }, + // threshold: 0 — any non-zero intersection counts as visible; this + // is a mount-gate, not a "is it prominently on screen" measurement. + { threshold: 0 }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + return [ref, isVisible]; +} diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 92740e8..96f67ff 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { cn, truncateAddress } from "./utils"; +import { cn, truncateAddress, validateStellarAddress } from "./utils"; describe("truncateAddress", () => { const STELLAR_ADDRESS_56 = @@ -44,6 +44,103 @@ describe("truncateAddress", () => { }); }); +describe("validateStellarAddress (#535)", () => { + // A well-known, publicly documented Stellar Development Foundation + // address — a real StrKey-encoded ED25519 public key, not a synthetic + // fixture, so this exercises the real base32 + CRC16/XMODEM checksum + // algorithm against ground truth rather than a value this test invented. + const VALID_ADDRESS = + "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ"; + + it("accepts a real, checksum-valid Stellar address", () => { + expect(validateStellarAddress(VALID_ADDRESS)).toBe(true); + }); + + it("trims surrounding whitespace before validating", () => { + expect(validateStellarAddress(` ${VALID_ADDRESS} `)).toBe(true); + }); + + it("rejects a single mutated character even though length and charset still match", () => { + // Second character changed (C -> D): still 56 chars, still valid + // base32 alphabet, starts with G — a format-only regex check would + // accept this. The checksum must not. + const mutated = "GDEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ"; + expect(validateStellarAddress(mutated)).toBe(false); + }); + + it("rejects a mutated character near the end of the payload", () => { + const mutated = "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JA"; + expect(validateStellarAddress(mutated)).toBe(false); + }); + + it("rejects strings not starting with G", () => { + expect( + validateStellarAddress( + "SCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", + ), + ).toBe(false); + }); + + it("rejects a Soroban contract address (C-prefixed) as a payment destination", () => { + // A contract address's StrKey version byte produces a 'C' prefix, so + // this is already caught by the format regex — kept as an explicit + // case since it's the specific confusion the issue calls out (an + // Ethereum-style or wrong-type address slipping through). + expect( + validateStellarAddress( + "CCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", + ), + ).toBe(false); + }); + + it("rejects a different StrKey type that also happens to start with 'G' (e.g. a signed-payload key)", () => { + // StrKey version bytes 48-55 all base32-encode to a 'G' first + // character - only 48 (0x30) is ED25519_PUBLIC_KEY. This address is a + // real, valid StrKey encoding (correct checksum, correct length) for + // ED25519_SIGNED_PAYLOAD (version 49/0x31) - the format regex alone + // cannot distinguish it from a payable account address, only decoding + // the version byte can. + const signedPayloadTypeAddress = + "GEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCO3K"; + expect(signedPayloadTypeAddress).toHaveLength(56); + expect(validateStellarAddress(signedPayloadTypeAddress)).toBe(false); + }); + + it("rejects strings shorter than 56 characters", () => { + expect(validateStellarAddress("GABC")).toBe(false); + }); + + it("rejects strings longer than 56 characters", () => { + expect(validateStellarAddress(`${VALID_ADDRESS}X`)).toBe(false); + }); + + it("rejects an empty string", () => { + expect(validateStellarAddress("")).toBe(false); + }); + + it("rejects non-Stellar-address strings entirely (e.g. an Ethereum address)", () => { + expect( + validateStellarAddress("0x71C7656EC7ab88b098defB751B7401B5f6d8976"), + ).toBe(false); + }); + + it("rejects lowercase input even if it would be valid uppercase", () => { + // StrKey's base32 alphabet is uppercase-only; lowercase must not be + // silently case-folded and accepted. + expect(validateStellarAddress(VALID_ADDRESS.toLowerCase())).toBe(false); + }); + + it("rejects characters outside the base32 alphabet (e.g. '0', '1', '8', '9')", () => { + // 56 characters total, matching length, but '0'/'1'/'8'/'9' are not in + // StrKey's base32 alphabet (A-Z, 2-7) — the format regex alone would + // already reject this too, but this test pins down that the character + // set is actually enforced, not just the length. + const withInvalidChars = `G0189A${"A".repeat(50)}`; + expect(withInvalidChars).toHaveLength(56); + expect(validateStellarAddress(withInvalidChars)).toBe(false); + }); +}); + describe("cn utility", () => { it("correctly merges conflicting Tailwind classes", () => { const result = cn("bg-red-500", "bg-blue-500"); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d242d0b..fc5715b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -25,13 +25,87 @@ export function safeFormat(balance: string): string { }); } +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/** + * Decodes an RFC 4648 base32 (no padding) string to raw bytes. Returns + * `null` for a string containing characters outside the base32 alphabet, + * or whose bit length doesn't cleanly resolve to a whole number of bytes + * with only zero-padding bits left over (a malformed encoding — a real + * StrKey never produces this). + */ +function base32Decode(input: string): Uint8Array | null { + let bits = 0; + let value = 0; + const bytes: number[] = []; + + for (const char of input) { + const charValue = BASE32_ALPHABET.indexOf(char); + if (charValue === -1) return null; + value = (value << 5) | charValue; + bits += 5; + if (bits >= 8) { + bits -= 8; + bytes.push((value >>> bits) & 0xff); + } + } + + // Any bits left over must be zero padding, never real data — a decoder + // that ignores this would silently accept a corrupted encoding. + const remainderMask = (1 << bits) - 1; + if (bits >= 8 || (value & remainderMask) !== 0) return null; + + return new Uint8Array(bytes); +} + /** - * Validate a Stellar public address: must start with 'G' and be 56 - * characters of base32 (RFC 4648, no padding) — the same charset the - * StrKey checksum encoding uses. + * CRC16/XMODEM (poly 0x1021, init 0x0000) over `bytes` — the checksum + * algorithm StrKey uses. Matches the reference implementation in + * stellar/js-stellar-base's strkey.ts. + */ +function crc16xmodem(bytes: Uint8Array): number { + let crc = 0; + for (const byte of bytes) { + crc ^= byte << 8; + for (let i = 0; i < 8; i++) { + crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; + } + } + return crc; +} + +/** StrKey version byte for an ED25519 public key ('G...' addresses). */ +const STRKEY_VERSION_ED25519_PUBLIC_KEY = 6 << 3; // 0x30 + +/** + * Validate a Stellar public address: decodes the full StrKey encoding + * (base32 → version byte → 32-byte raw key → CRC16/XMODEM checksum) rather + * than only checking the surface format. A string that merely matches + * `/^G[A-Z2-7]{55}$/` can still fail this — e.g. a single mistyped + * character produces a different checksum, so a format-only check would + * accept it and let it reach the network, where it fails with an opaque + * error rather than a clear one at input time. + * + * Deliberately re-implemented here (not imported from `@stellar/stellar-sdk` + * or another blockchain library) — this package has no blockchain-logic + * dependency by design (see README); StrKey decoding is pure data + * validation, not chain interaction, so it stays in scope for that + * constraint without pulling in a full SDK for one function. */ export function validateStellarAddress(address: string): boolean { - return /^G[A-Z2-7]{55}$/.test(address.trim()); + const trimmed = address.trim(); + if (!/^G[A-Z2-7]{55}$/.test(trimmed)) return false; + + const decoded = base32Decode(trimmed); + // 1 version byte + 32 key bytes + 2 checksum bytes = 35. + if (!decoded || decoded.length !== 35) return false; + + const [version] = decoded; + if (version !== STRKEY_VERSION_ED25519_PUBLIC_KEY) return false; + + const payload = decoded.subarray(0, 33); // version byte + key + const expectedChecksum = decoded[33] | (decoded[34] << 8); // little-endian + return crc16xmodem(payload) === expectedChecksum; } const STROOPS_PER_XLM = 10_000_000;