diff --git a/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx b/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx
index a1457ee..5cba042 100644
--- a/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx
+++ b/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx
@@ -5,14 +5,12 @@ import { Buffer } from "buffer";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ProposalSummaryCard } from "@/components/ProposalSummaryCard";
-import { AsyncState } from "@/components/ui/AsyncState";
-import { EmptyState } from "@/components/ui/EmptyState";
-import { ErrorState } from "@/components/ui/ErrorState";
-import { FreshnessNotice } from "@/components/ui/FreshnessNotice";
+import { DiscoveryFreshnessBanner } from "@/components/DiscoveryFreshnessBanner";
+import { LiveStatus } from "@/components/ui/LiveStatus";
import { Skeleton } from "@/components/ui/Skeleton";
import { useProposalDiscovery } from "@/hooks/useProposalDiscovery";
-import { useCommunityRegistry } from "@/lib/community/CommunityRegistryProvider";
-import type { Community } from "@/lib/community/types";
+import { getCommunity } from "@/lib/community/registry";
+import type { CommunityView } from "@/lib/community/types";
import { createReadOnlyGovernorClient } from "@/lib/contracts";
import {
ProposalState,
@@ -23,10 +21,16 @@ import {
const PAGE_SIZE = 10;
const ALL_STATES = "all";
-function ScopedProposalHistory({ community }: { community: Community }) {
+function ScopedProposalHistory({ community }: { community: CommunityView }) {
const governorContract = community.record.governorContract;
- const { proposals: discovered, loading, error, empty, refresh } =
- useProposalDiscovery(governorContract);
+ const {
+ proposals: discovered,
+ loading,
+ error,
+ empty,
+ freshness,
+ refresh,
+ } = useProposalDiscovery(governorContract);
const proposals = useMemo(
() =>
Array.from(
@@ -48,7 +52,7 @@ function ScopedProposalHistory({ community }: { community: Community }) {
try {
const client = createReadOnlyGovernorClient(governorContract);
const transaction = await client.proposal_state({
- proposal_id: Buffer.from(proposalId, "hex"),
+ proposal_id: Uint8Array.from(Buffer.from(proposalId, "hex")),
});
setStates((current) => ({
...current,
@@ -153,9 +157,9 @@ function ScopedProposalHistory({ community }: { community: Community }) {
{loading && proposals.length === 0 && (
<>
-
+
Loading community proposal history…
-
+
@@ -164,24 +168,37 @@ function ScopedProposalHistory({ community }: { community: Community }) {
)}
{error && (
-
void refresh()}
- retryLabel="Retry proposal history"
+
- {error}
-
+
+ {proposals.length
+ ? "More proposal history could not be loaded."
+ : "Proposal history is temporarily unavailable."}
+
+
{error}
+
void refresh()}
+ className="mt-3 min-h-11 rounded-lg border border-rose-700 px-3 py-2"
+ >
+ Retry proposal history
+
+
+ )}
+
+ {!loading && proposals.length > 0 && (
+ void refresh()}
+ />
)}
{!loading && !error && empty && (
-
+
This community has no public proposals yet.
-
+
)}
{!loading && proposals.length > 0 && filtered.length === 0 && (
@@ -191,53 +208,45 @@ function ScopedProposalHistory({ community }: { community: Community }) {
)}
{visible.length > 0 && (
- <>
- {Object.values(states).includes("unavailable") && (
-
- Some proposal states could not be refreshed. Available
- proposals remain visible below.
-
- )}
-
- {visible.map((proposal) => {
- const state = states[proposal.id];
- const unavailable = state === "unavailable";
- return (
-
- void retryState(proposal.id)
- : undefined
- }
- isRetryingState={retrying.includes(proposal.id)}
- onCopyId={() =>
- void navigator.clipboard.writeText(proposal.id)
- }
- />
-
- );
- })}
-
- >
+
+ {visible.map((proposal) => {
+ const state = states[proposal.id];
+ const unavailable = state === "unavailable";
+ return (
+
+ void retryState(proposal.id)
+ : undefined
+ }
+ isRetryingState={retrying.includes(proposal.id)}
+ onCopyId={() =>
+ void navigator.clipboard.writeText(proposal.id)
+ }
+ />
+
+ );
+ })}
+
)}
{visibleCount < filtered.length && (
@@ -256,8 +265,7 @@ function ScopedProposalHistory({ community }: { community: Community }) {
export default function CommunityProposalHistoryPage() {
const { id = "" } = useParams<{ id: string }>();
- const registry = useCommunityRegistry();
- const [community, setCommunity] = useState(null);
+ const [community, setCommunity] = useState(null);
const [status, setStatus] = useState<"loading" | "not-found" | "error">(
"loading",
);
@@ -267,7 +275,7 @@ export default function CommunityProposalHistoryPage() {
const timeout = window.setTimeout(() => {
setStatus("loading");
setCommunity(null);
- void registry.get(id)
+ void getCommunity(id)
.then((result) => {
if (!active) return;
if (result.status !== "found") {
@@ -284,34 +292,36 @@ export default function CommunityProposalHistoryPage() {
active = false;
window.clearTimeout(timeout);
};
- }, [id, registry]);
+ }, [id]);
if (community) return ;
return (
{status === "loading" ? (
-
Loading community proposal history…
+
Loading community proposal history…
) : (
-
- Browse communities
-
- }
+
- {status === "not-found"
- ? "The route community is malformed or is not registered."
- : "The canonical community record could not be loaded."}
-
+
+ {status === "not-found"
+ ? "Community not found"
+ : "Community proposal history unavailable"}
+
+
+ {status === "not-found"
+ ? "The route community is malformed or is not registered."
+ : "The canonical community record could not be loaded."}
+
+
+ Browse communities
+
+
)}
);
diff --git a/apps/web/src/app/(app)/proposals/page.tsx b/apps/web/src/app/(app)/proposals/page.tsx
index aadbb8c..cb3465f 100644
--- a/apps/web/src/app/(app)/proposals/page.tsx
+++ b/apps/web/src/app/(app)/proposals/page.tsx
@@ -13,6 +13,7 @@ import {
import { contractIds } from "@/lib/stellar";
import { Skeleton } from "@/components/ui/Skeleton";
import { ProposalSummaryCard } from "@/components/ProposalSummaryCard";
+import { DiscoveryFreshnessBanner } from "@/components/DiscoveryFreshnessBanner";
import { truncateEnd } from "@/lib/truncate";
import { LiveStatus } from "@/components/ui/LiveStatus";
import { TransactionLifecycleStatus } from "@/components/TransactionLifecycleStatus";
@@ -46,6 +47,7 @@ export default function ProposalsPage() {
loading,
error,
empty,
+ freshness,
refresh,
} =
useProposalDiscovery();
@@ -479,6 +481,13 @@ export default function ProposalsPage() {
)}
+ {!loading && uniqueProposalIds.length > 0 && (
+ void refresh()}
+ />
+ )}
+
{!loading && !error && empty && (
No public proposals have been discovered yet.
diff --git a/apps/web/src/components/DiscoveryFreshnessBanner.tsx b/apps/web/src/components/DiscoveryFreshnessBanner.tsx
new file mode 100644
index 0000000..e549173
--- /dev/null
+++ b/apps/web/src/components/DiscoveryFreshnessBanner.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import type { FreshnessResult } from "@/lib/proposal/freshness";
+
+const STATE_STYLES: Record<
+ FreshnessResult["state"],
+ { className: string; icon: string }
+> = {
+ current: {
+ className: "border-emerald-800/60 bg-emerald-950/40 text-emerald-200",
+ icon: "\u2713",
+ },
+ delayed: {
+ className: "border-amber-800/60 bg-amber-950/40 text-amber-200",
+ icon: "\u25B2",
+ },
+ stale: {
+ className: "border-amber-800/70 bg-amber-950/50 text-amber-200",
+ icon: "\u25B2",
+ },
+ unavailable: {
+ className: "border-rose-800/70 bg-rose-950/40 text-rose-200",
+ icon: "\u2717",
+ },
+};
+
+const EXPLANATION_URL =
+ "https://github.com/stolla-labs/stolla/blob/main/docs/community-proposal-indexing.md#finality-freshness-and-caching";
+
+export type DiscoveryFreshnessBannerProps = {
+ freshness: FreshnessResult;
+ /** When provided, render a retry button that calls this callback. */
+ onRetry?: () => void;
+ isRetrying?: boolean;
+};
+
+/**
+ * Banner indicating the freshness of proposal discovery results.
+ *
+ * Renders nothing for the `current` state — users don't need to know
+ * that everything is fine. All other states show a banner with an
+ * explanation, a link to documentation, and an optional retry action.
+ */
+export function DiscoveryFreshnessBanner({
+ freshness,
+ onRetry,
+ isRetrying = false,
+}: DiscoveryFreshnessBannerProps) {
+ if (!freshness || freshness.state === "current") return null;
+
+ const { className, icon } = STATE_STYLES[freshness.state];
+
+ return (
+
+
+ {icon}
+ {freshness.explanation}
+
+
+
+ );
+}
diff --git a/apps/web/src/hooks/useProposalDiscovery.ts b/apps/web/src/hooks/useProposalDiscovery.ts
index e767bc5..4b2ef27 100644
--- a/apps/web/src/hooks/useProposalDiscovery.ts
+++ b/apps/web/src/hooks/useProposalDiscovery.ts
@@ -1,54 +1,65 @@
"use client";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { Server as RpcServer } from "@stellar/stellar-sdk/rpc";
+import type { Api } from "@stellar/stellar-sdk/rpc";
+import { config, requireContractIds, requireGovernorStartLedger } from "@/lib/stellar";
+import { decodeProposalEvent } from "@/lib/proposalEvents";
import {
- config,
- requireContractIds,
- requireGovernorStartLedger,
-} from "@/lib/stellar";
-import {
- decodeProposalEvent,
- fetchGovernorEvents,
-} from "@/lib/proposal-events";
+ evaluateDiscoveryFreshness,
+ type FreshnessResult,
+} from "@/lib/proposal/freshness";
import { getE2EBridge } from "@/lib/e2eMock";
-import { parseProposalDescription, type ProposalMetadataV1 } from "@/lib/proposal-metadata";
export type DiscoveredProposal = {
id: string;
/** Proposal description from the created event, or null when unavailable. */
description: string | null;
- /** Parsed v1 metadata, or null for legacy / unavailable descriptions. */
- metadata: ProposalMetadataV1 | null;
};
-/**
- * Discover proposals for a Governor via the shared event pipeline.
- *
- * - Omit `governorContractId` for the legacy global surface (env Governor).
- * - When Community (or any caller) passes an explicit id, that contract is
- * used exclusively — never silently fall back to the env global Governor.
- */
+function extractDescription(event: Api.EventResponse): string | null {
+ const decoded = decodeProposalEvent({
+ type: event.type,
+ contractId: event.contractId,
+ topic: event.topic,
+ value: event.value,
+ });
+ if (decoded.ok && decoded.event.kind === "proposal_created") {
+ return decoded.event.description;
+ }
+
+ try {
+ if (event.value.switch().name !== "scvVec") return null;
+ const fields = event.value.vec();
+ const descriptionVal = fields?.[5];
+ if (!descriptionVal || descriptionVal.switch().name !== "scvString") {
+ return null;
+ }
+ return descriptionVal.str() as string;
+ } catch {
+ return null;
+ }
+}
+
export function useProposalDiscovery(governorContractId?: string) {
const [proposals, setProposals] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [empty, setEmpty] = useState(false);
+ const [freshnessMeta, setFreshnessMeta] = useState<{
+ latestLedger: number | null;
+ lastEventLedger: number | null;
+ discoveredCount: number;
+ hadError: boolean;
+ }>({
+ latestLedger: null,
+ lastEventLedger: null,
+ discoveredCount: 0,
+ hadError: false,
+ });
const discover = useCallback(async () => {
- const governor =
- governorContractId !== undefined
- ? governorContractId
- : requireContractIds().governor;
-
- if (!governor) {
- setError(
- "Governor contract ID is not configured. Set NEXT_PUBLIC_GOVERNOR_CONTRACT_ID.",
- );
- setLoading(false);
- return false;
- }
-
+ const governor = governorContractId ?? requireContractIds().governor;
const server = new RpcServer(config.rpcUrl);
const startLedger = requireGovernorStartLedger();
@@ -59,54 +70,109 @@ export function useProposalDiscovery(governorContractId?: string) {
try {
const mocked = getE2EBridge()?.proposals?.[governor];
if (mocked) {
- setProposals(mocked.map((proposal) => ({
- ...proposal,
- metadata: proposal.description
- ? (() => {
- const parsed = parseProposalDescription(proposal.description);
- return parsed.kind === "versioned" ? parsed.metadata : null;
- })()
- : null,
- })));
+ setProposals(mocked);
setEmpty(mocked.length === 0);
+ setFreshnessMeta({
+ latestLedger: null,
+ lastEventLedger: null,
+ discoveredCount: mocked.length,
+ hadError: false,
+ });
return true;
}
-
const discovered: DiscoveredProposal[] = [];
+ let cursor: string | undefined = undefined;
+ let latestLedger: number | null = null;
+ let lastEventLedger: number | null = null;
+ let hadError = false;
- for await (const page of fetchGovernorEvents({
- server,
- contractId: governor,
- startLedger,
- })) {
- for (const event of page.events) {
- const decoded = decodeProposalEvent(
- {
- type: event.type,
- contractId: event.contractId,
- topic: event.topic,
- value: event.value,
- },
- { expectedContractId: governor },
- );
- if (!decoded.ok || decoded.event.kind !== "proposal_created") {
- continue;
- }
- const parsed = parseProposalDescription(decoded.event.description);
+ for (;;) {
+ // Topic filters against current testnet RPC return empty for OZ
+ // contract-event symbols; fetch by contract id and filter client-side.
+ const request:
+ | {
+ filters: { contractIds: string[] }[];
+ startLedger: number;
+ limit?: number;
+ cursor?: never;
+ }
+ | {
+ filters: { contractIds: string[] }[];
+ cursor: string;
+ startLedger?: never;
+ limit?: number;
+ } = cursor
+ ? {
+ filters: [{ contractIds: [governor] }],
+ cursor,
+ limit: 100,
+ }
+ : {
+ filters: [{ contractIds: [governor] }],
+ startLedger,
+ limit: 100,
+ };
+
+ let response: Awaited>;
+ try {
+ response = await server.getEvents(request);
+ } catch {
+ hadError = true;
+ break;
+ }
+
+ // Capture the RPC head ledger from the first page.
+ if (latestLedger === null && response.latestLedger) {
+ latestLedger = response.latestLedger;
+ }
+
+ for (const event of response.events) {
+ if (event.topic.length < 2) continue;
+ const kind = event.topic[0];
+ const kindName =
+ kind.switch().name === "scvSymbol"
+ ? kind.sym().toString()
+ : kind.switch().name === "scvString"
+ ? kind.str().toString()
+ : "";
+ if (kindName !== "proposal_created") continue;
+ const proposalIdScVal = event.topic[1];
+ if (proposalIdScVal.switch().name !== "scvBytes") continue;
+ const proposalIdBytes = proposalIdScVal.bytes();
+ if (!proposalIdBytes) continue;
discovered.push({
- id: decoded.event.proposalId,
- description: decoded.event.description,
- metadata: parsed.kind === "versioned" ? parsed.metadata : null,
+ id: Buffer.from(proposalIdBytes).toString("hex"),
+ description: extractDescription(event),
});
+
+ // Track the highest ledger sequence from event metadata.
+ const eventLedger =
+ typeof event.ledger === "number" ? event.ledger : null;
+ if (eventLedger !== null) {
+ lastEventLedger =
+ lastEventLedger === null
+ ? eventLedger
+ : Math.max(lastEventLedger, eventLedger);
+ }
}
+
+ if (!response.events.length || !response.cursor) break;
+ cursor = response.cursor;
}
discovered.reverse();
setProposals(discovered);
setEmpty(discovered.length === 0);
+ setFreshnessMeta({
+ latestLedger,
+ lastEventLedger,
+ discoveredCount: discovered.length,
+ hadError,
+ });
return true;
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Discovery failed");
+ setFreshnessMeta((prev) => ({ ...prev, hadError: true }));
return false;
} finally {
setLoading(false);
@@ -123,12 +189,18 @@ export function useProposalDiscovery(governorContractId?: string) {
const proposalIds = proposals.map((proposal) => proposal.id);
+ const freshness: FreshnessResult = useMemo(
+ () => evaluateDiscoveryFreshness(freshnessMeta),
+ [freshnessMeta],
+ );
+
return {
proposals,
proposalIds,
loading,
error,
empty,
+ freshness,
refresh: discover,
};
}
diff --git a/apps/web/src/lib/proposal-events/freshness.test.ts b/apps/web/src/lib/proposal-events/freshness.test.ts
new file mode 100644
index 0000000..783c6a1
--- /dev/null
+++ b/apps/web/src/lib/proposal-events/freshness.test.ts
@@ -0,0 +1,289 @@
+import { describe, expect, it } from "vitest";
+import {
+ evaluateDiscoveryFreshness,
+ CURRENT_THRESHOLD,
+ STALE_THRESHOLD,
+} from "./freshness";
+import type { FreshnessMetadata } from "./freshness";
+
+// ---------------------------------------------------------------------------
+// Shared test fixtures
+// ---------------------------------------------------------------------------
+
+function meta(overrides: Partial = {}): FreshnessMetadata {
+ return {
+ latestLedger: 1_000_100,
+ lastEventLedger: 1_000_095,
+ discoveredCount: 3,
+ hadError: false,
+ ...overrides,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Current state
+// ---------------------------------------------------------------------------
+
+describe("evaluateDiscoveryFreshness", () => {
+ describe("current", () => {
+ it("returns current when gap is zero", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({ latestLedger: 1_000_100, lastEventLedger: 1_000_100 }),
+ );
+ expect(result.state).toBe("current");
+ expect(result.ledgerGap).toBe(0);
+ });
+
+ it("returns current when gap is within CURRENT_THRESHOLD", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: 1_000_100,
+ lastEventLedger: 1_000_100 - CURRENT_THRESHOLD,
+ }),
+ );
+ expect(result.state).toBe("current");
+ expect(result.ledgerGap).toBe(CURRENT_THRESHOLD);
+ });
+
+ it("returns current when lastEventLedger exceeds latestLedger (negative gap)", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({ latestLedger: 1_000_090, lastEventLedger: 1_000_100 }),
+ );
+ expect(result.state).toBe("current");
+ expect(result.ledgerGap).toBe(-10);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Delayed state
+ // -------------------------------------------------------------------------
+
+ describe("delayed", () => {
+ it("returns delayed when gap exceeds CURRENT_THRESHOLD but is within STALE_THRESHOLD", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: 1_000_200,
+ lastEventLedger: 1_000_100,
+ }),
+ );
+ expect(result.state).toBe("delayed");
+ expect(result.ledgerGap).toBe(100);
+ });
+
+ it("returns delayed when gap equals STALE_THRESHOLD", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: 1_000_100 + STALE_THRESHOLD,
+ lastEventLedger: 1_000_100,
+ }),
+ );
+ expect(result.state).toBe("delayed");
+ expect(result.ledgerGap).toBe(STALE_THRESHOLD);
+ });
+
+ it("returns delayed when latestLedger is null (unknown head)", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({ latestLedger: null }),
+ );
+ expect(result.state).toBe("delayed");
+ expect(result.ledgerGap).toBeNull();
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Stale state
+ // -------------------------------------------------------------------------
+
+ describe("stale", () => {
+ it("returns stale when gap exceeds STALE_THRESHOLD", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: 1_000_100 + STALE_THRESHOLD + 1,
+ lastEventLedger: 1_000_100,
+ }),
+ );
+ expect(result.state).toBe("stale");
+ expect(result.ledgerGap).toBe(STALE_THRESHOLD + 1);
+ });
+
+ it("returns stale when hadError is true and data exists", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({ hadError: true, discoveredCount: 2 }),
+ );
+ expect(result.state).toBe("stale");
+ });
+
+ it("stale result includes explanatory message about network errors", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({ hadError: true, discoveredCount: 5 }),
+ );
+ expect(result.explanation).toContain("network errors");
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Unavailable state
+ // -------------------------------------------------------------------------
+
+ describe("unavailable", () => {
+ it("returns unavailable when no events and RPC failed", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: null,
+ lastEventLedger: null,
+ discoveredCount: 0,
+ hadError: true,
+ }),
+ );
+ expect(result.state).toBe("unavailable");
+ expect(result.ledgerGap).toBeNull();
+ });
+
+ it("returns unavailable when no events and no RPC head", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: null,
+ lastEventLedger: null,
+ discoveredCount: 0,
+ hadError: false,
+ }),
+ );
+ expect(result.state).toBe("unavailable");
+ });
+
+ it("returns unavailable when no events found in scanned range", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: 1_000_100,
+ lastEventLedger: null,
+ discoveredCount: 0,
+ hadError: false,
+ }),
+ );
+ expect(result.state).toBe("unavailable");
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Malformed / edge-case RPC metadata
+ // -------------------------------------------------------------------------
+
+ describe("malformed RPC metadata", () => {
+ it("handles zero latestLedger gracefully", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({ latestLedger: 0, lastEventLedger: 0, discoveredCount: 1 }),
+ );
+ expect(result.state).toBe("current");
+ expect(result.ledgerGap).toBe(0);
+ });
+
+ it("handles lastEventLedger greater than latestLedger", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({ latestLedger: 100, lastEventLedger: 200, discoveredCount: 1 }),
+ );
+ expect(result.state).toBe("current");
+ expect(result.ledgerGap).toBe(-100);
+ });
+
+ it("handles both ledger fields as null with no data", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: null,
+ lastEventLedger: null,
+ discoveredCount: 0,
+ hadError: false,
+ }),
+ );
+ expect(result.state).toBe("unavailable");
+ });
+
+ it("handles both ledger fields as null but data exists", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: null,
+ lastEventLedger: null,
+ discoveredCount: 2,
+ hadError: false,
+ }),
+ );
+ // Has data but no way to compute gap → delayed (unknown head)
+ expect(result.state).toBe("delayed");
+ });
+
+ it("handles negative latestLedger from malformed RPC", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: -1,
+ lastEventLedger: 100,
+ discoveredCount: 1,
+ }),
+ );
+ // Negative head means gap = -1 - 100 = -101 → current (negative gap)
+ expect(result.state).toBe("current");
+ });
+
+ it("handles discoveredCount as zero with lastEventLedger present (defensive)", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: 1_000_100,
+ lastEventLedger: 1_000_095,
+ discoveredCount: 0,
+ }),
+ );
+ // Edge case: lastEventLedger set but no proposals counted.
+ // This shouldn't happen in practice but the function handles it.
+ expect(result.state).toBe("current");
+ });
+
+ it("handles very large gap (RPC retention overflow)", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: 10_000_000,
+ lastEventLedger: 1_000_000,
+ discoveredCount: 5,
+ }),
+ );
+ expect(result.state).toBe("stale");
+ expect(result.ledgerGap).toBe(9_000_000);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Explanation strings
+ // -------------------------------------------------------------------------
+
+ describe("explanations", () => {
+ it("current explanation is actionable", () => {
+ const result = evaluateDiscoveryFreshness(meta());
+ expect(result.explanation).toBeTruthy();
+ expect(typeof result.explanation).toBe("string");
+ });
+
+ it("delayed explanation mentions proposals may not appear", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: 1_000_200,
+ lastEventLedger: 1_000_100,
+ }),
+ );
+ expect(result.explanation).toContain("behind");
+ });
+
+ it("stale explanation mentions incompleteness", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({
+ latestLedger: 1_000_300,
+ lastEventLedger: 1_000_100,
+ }),
+ );
+ expect(result.explanation).toContain("incomplete");
+ });
+
+ it("unavailable explanation is non-empty", () => {
+ const result = evaluateDiscoveryFreshness(
+ meta({ discoveredCount: 0, lastEventLedger: null, hadError: true }),
+ );
+ expect(result.explanation.length).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/apps/web/src/lib/proposal-events/freshness.ts b/apps/web/src/lib/proposal-events/freshness.ts
index 129dabb..b3f9e29 100644
--- a/apps/web/src/lib/proposal-events/freshness.ts
+++ b/apps/web/src/lib/proposal-events/freshness.ts
@@ -1,56 +1,156 @@
/**
- * Client-side Freshness State types matching
- * `docs/community-proposal-indexing.md` public sync contract.
+ * Proposal discovery freshness evaluation.
*
- * Until the shared indexer lands, direct-RPC discovery can surface a
- * stub sync payload so Community and global consumers share one shape.
+ * Derives a user-facing freshness label from the metadata returned by
+ * paginated RPC event scans — never from wall-clock guesses alone.
+ *
+ * Thresholds follow PRD §5.1 as adapted for direct browser-to-RPC
+ * discovery (no backend indexer):
+ *
+ * Current – scan completed, last event ledger within {@link CURRENT_THRESHOLD}
+ * of the RPC's latest ledger, no errors.
+ * Delayed – scan completed but the gap between last event and latest
+ * ledger exceeds {@link CURRENT_THRESHOLD} and is within
+ * {@link STALE_THRESHOLD}.
+ * Stale – gap exceeds {@link STALE_THRESHOLD}, or the RPC reported
+ * errors during pagination even though some data was returned.
+ * Unavailable – no data was returned (empty range or complete RPC failure).
*/
-/** Indexer / discovery pipeline status for a community proposal feed. */
-export type ProposalSyncStatus =
- | "ready"
- | "syncing"
- | "partial"
- | "unavailable";
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export type FreshnessState = "current" | "delayed" | "stale" | "unavailable";
/**
- * Freshness label relative to observed head and finality lag.
- * See community-proposal-indexing.md § Finality, freshness, and caching.
+ * Metadata extracted from paginated RPC event responses.
+ * All fields are nullable because the RPC may not return them or the
+ * discovery scan may not have reached a page that provides them.
*/
-export type ProposalFreshness = "fresh" | "stale" | "unknown";
-
-export interface ProposalSyncState {
- status: ProposalSyncStatus;
- freshness: ProposalFreshness;
- indexedThroughLedger: number | null;
- observedHeadLedger: number | null;
- lastSuccessfulSync: string | null;
- warnings: string[];
+export interface FreshnessMetadata {
+ /** Latest ledger known to the RPC at the time of the first page. */
+ latestLedger: number | null;
+ /** Highest ledger sequence observed across all returned events. */
+ lastEventLedger: number | null;
+ /** Number of proposals successfully discovered. */
+ discoveredCount: number;
+ /** Whether any paginated page returned an error. */
+ hadError: boolean;
}
-export interface ClientFreshnessStubOptions {
- status?: ProposalSyncStatus;
- freshness?: ProposalFreshness;
- indexedThroughLedger?: number | null;
- observedHeadLedger?: number | null;
- lastSuccessfulSync?: string | null;
- warnings?: string[];
+/**
+ * Full result produced by {@link evaluateDiscoveryFreshness}.
+ * Includes the state plus diagnostic fields for the UI banner.
+ */
+export interface FreshnessResult {
+ state: FreshnessState;
+ /** Human-readable explanation for the current state. */
+ explanation: string;
+ /** Gap between latest ledger and last event ledger, or null if unknown. */
+ ledgerGap: number | null;
}
+// ---------------------------------------------------------------------------
+// Thresholds (ledgers)
+// ---------------------------------------------------------------------------
+
+/**
+ * Maximum gap between `lastEventLedger` and `latestLedger` for the data
+ * to be considered Current. Five ledgers ≈ a few seconds of network time.
+ */
+export const CURRENT_THRESHOLD = 5;
+
+/**
+ * Gap beyond which data is considered Stale rather than merely Delayed.
+ * One hundred ledgers ≈ ~50 seconds on Stellar mainnet.
+ */
+export const STALE_THRESHOLD = 100;
+
+// ---------------------------------------------------------------------------
+// Evaluation
+// ---------------------------------------------------------------------------
+
/**
- * Build a client-side sync stub for direct-RPC discovery.
- * Not a substitute for indexer freshness; call sites must not present
- * this as authoritative multi-community indexing state.
+ * Evaluate the freshness of a proposal discovery result from RPC metadata.
+ *
+ * The function is pure — no side-effects, no React dependency, no
+ * wall-clock access. It can run in Node, a browser, or a test.
*/
-export function createClientFreshnessStub(
- options: ClientFreshnessStubOptions = {},
-): ProposalSyncState {
+export function evaluateDiscoveryFreshness(
+ meta: FreshnessMetadata,
+): FreshnessResult {
+ const { latestLedger, lastEventLedger, discoveredCount, hadError } = meta;
+
+ // --- Unavailable: no data at all ---
+ if (discoveredCount === 0 && lastEventLedger === null) {
+ // If the RPC itself failed, it's unavailable regardless.
+ if (hadError || latestLedger === null) {
+ return {
+ state: "unavailable",
+ explanation: "Proposal history could not be loaded.",
+ ledgerGap: null,
+ };
+ }
+
+ // RPC responded but no events found — distinguish "no proposals exist"
+ // from "scan range is too narrow". Without a backend indexer we cannot
+ // know, so report unavailable with an actionable hint.
+ return {
+ state: "unavailable",
+ explanation: "No proposal events were found in the scanned ledger range.",
+ ledgerGap: null,
+ };
+ }
+
+ // --- Compute the ledger gap ---
+ const gap =
+ latestLedger !== null && lastEventLedger !== null
+ ? latestLedger - lastEventLedger
+ : null;
+
+ // --- Errors during pagination → Stale (data exists but is unreliable) ---
+ if (hadError && discoveredCount > 0) {
+ return {
+ state: "stale",
+ explanation:
+ "Some proposal history could not be loaded due to network errors.",
+ ledgerGap: gap,
+ };
+ }
+
+ // --- No RPC head available (shouldn't happen with a live RPC) ---
+ if (latestLedger === null) {
+ return {
+ state: "delayed",
+ explanation: "Proposal history is loading but the network head is unknown.",
+ ledgerGap: null,
+ };
+ }
+
+ // --- Gap-based classification ---
+ if (gap !== null && gap <= CURRENT_THRESHOLD) {
+ return {
+ state: "current",
+ explanation: "Proposal history is up to date.",
+ ledgerGap: gap,
+ };
+ }
+
+ if (gap !== null && gap <= STALE_THRESHOLD) {
+ return {
+ state: "delayed",
+ explanation:
+ "Proposal history is slightly behind the network. New proposals may not appear yet.",
+ ledgerGap: gap,
+ };
+ }
+
+ // Gap > STALE_THRESHOLD
return {
- status: options.status ?? "ready",
- freshness: options.freshness ?? "unknown",
- indexedThroughLedger: options.indexedThroughLedger ?? null,
- observedHeadLedger: options.observedHeadLedger ?? null,
- lastSuccessfulSync: options.lastSuccessfulSync ?? null,
- warnings: options.warnings ?? [],
+ state: "stale",
+ explanation:
+ "Proposal history is significantly behind the network. Results may be incomplete.",
+ ledgerGap: gap,
};
}
diff --git a/apps/web/src/test/proposal-discovery.test.tsx b/apps/web/src/test/proposal-discovery.test.tsx
index d52f710..f682ea5 100644
--- a/apps/web/src/test/proposal-discovery.test.tsx
+++ b/apps/web/src/test/proposal-discovery.test.tsx
@@ -9,6 +9,11 @@ vi.mock("@/hooks/useProposalDiscovery", () => ({
loading: false,
error: null,
empty: false,
+ freshness: {
+ state: "current",
+ explanation: "Proposal history is up to date.",
+ ledgerGap: 0,
+ },
refresh: vi.fn(),
})),
}));