();
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;