diff --git a/app/src/agentworld/pages/FeedSection.test.tsx b/app/src/agentworld/pages/FeedSection.test.tsx index b9352efa7..628e34083 100644 --- a/app/src/agentworld/pages/FeedSection.test.tsx +++ b/app/src/agentworld/pages/FeedSection.test.tsx @@ -254,6 +254,42 @@ describe('Feed list', () => { }); }); + test('does NOT call homeFeed when no wallet is configured (prevention at source)', async () => { + // wallet_status resolves with no Solana account → no wallet configured. + vi.mocked(fetchWalletStatus).mockResolvedValue({ accounts: [] } as any); + vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); + render(); + await waitFor(() => { + expect(screen.getByText(/set up your wallet to view your feed/i)).toBeInTheDocument(); + }); + // The wallet-requiring RPC must never be invoked for a wallet-less user. + expect(vi.mocked(apiClient.graphql.homeFeed)).not.toHaveBeenCalled(); + }); + + test('still calls homeFeed when wallet IS configured', async () => { + vi.mocked(fetchWalletStatus).mockResolvedValue({ + accounts: [{ chain: 'solana', address: MY_AGENT_ID }], + } as any); + vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); + render(); + await waitFor(() => { + expect(screen.getByText('Hello from the network')).toBeInTheDocument(); + }); + expect(vi.mocked(apiClient.graphql.homeFeed)).toHaveBeenCalled(); + }); + + test('falls through to homeFeed when wallet status fetch fails (unknown)', async () => { + // A transport/RPC failure is "unknown", not "unconfigured": proceed and let + // the backend boundary classifier handle any wallet-locked error. + vi.mocked(fetchWalletStatus).mockRejectedValue(new Error('core rpc unavailable')); + vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); + render(); + await waitFor(() => { + expect(screen.getByText('Hello from the network')).toBeInTheDocument(); + }); + expect(vi.mocked(apiClient.graphql.homeFeed)).toHaveBeenCalled(); + }); + test('tolerates response missing items field and shows empty state', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({} as any); diff --git a/app/src/agentworld/pages/FeedSection.tsx b/app/src/agentworld/pages/FeedSection.tsx index aea13e19e..51edc06f8 100644 --- a/app/src/agentworld/pages/FeedSection.tsx +++ b/app/src/agentworld/pages/FeedSection.tsx @@ -35,10 +35,30 @@ const log = debug('agentworld:feed'); type FeedState = | { status: 'loading' } + | { status: 'wallet_unconfigured' } | { status: 'payment_required'; challenge: unknown } | { status: 'error'; message: string } | { status: 'ok'; items: GqlHomeFeedItem[] }; +/** + * Result of resolving the local wallet on mount. + * + * `configured`: + * - `'resolving'` → wallet_status still in flight; callers must NOT fire + * wallet-requiring RPCs yet. + * - `'no'` → wallet_status resolved with no usable (Solana) account, + * i.e. no wallet is configured at all. This is the only state where we have + * a positive lever to skip the wallet-gated RPC entirely. + * - `'yes'` → a usable wallet account exists. + * - `'unknown'` → wallet_status fetch failed (transport/RPC error). We can't + * prove the wallet is absent, so callers should proceed and let the backend + * boundary classifier handle any wallet-locked error (defense-in-depth). + * + * `agentId` is the resolved Solana address when one exists, else `null`. + */ +type WalletConfigured = 'resolving' | 'no' | 'yes' | 'unknown'; +type WalletResolution = { agentId: string | null; configured: WalletConfigured }; + // ── Helpers ─────────────────────────────────────────────────────────────────── function relativeTime(iso: string): string { @@ -102,19 +122,49 @@ function InitialAvatar({ name }: { name: string }) { ); } -// ── useMyAgentId ────────────────────────────────────────────────────────────── +// ── useWalletResolution ─────────────────────────────────────────────────────── -function useMyAgentId(): string | null { - const [agentId, setAgentId] = useState(null); +/** + * Resolve the local wallet once on mount. + * + * Mirrors WalletAddressChip's convention: a wallet is "configured" (usable for + * the wallet-gated feed RPCs) when wallet_status resolves with a Solana account. + * A successful response with no Solana account means no wallet is set up. A + * rejected fetch (transport/RPC error) is treated as "unknown" — we leave + * `configured` null so the caller surfaces a transient error rather than + * mislabelling a configured wallet as unconfigured. + * + * Exposing the tri-state lets FeedSection gate the wallet-requiring `homeFeed()` + * fetch on wallet status *before* invoking it — so wallet-less users never hit + * the RPC and trip the boundary classifier. + */ +function useWalletResolution(): WalletResolution { + const [resolution, setResolution] = useState({ + agentId: null, + configured: 'resolving', + }); useEffect(() => { + let cancelled = false; void fetchWalletStatus() .then(status => { + if (cancelled) return; const solana = (status.accounts ?? []).find(a => a.chain === 'solana'); - if (solana?.address) setAgentId(solana.address); + const address = solana?.address ?? null; + setResolution({ agentId: address, configured: address !== null ? 'yes' : 'no' }); }) - .catch(() => {}); + .catch(() => { + // Transport/RPC failure: we can't prove the wallet is absent, so mark + // it 'unknown' — the feed proceeds and the backend boundary classifier + // handles any wallet-locked error rather than us showing a false + // "not configured" state for a wallet that may well exist. + if (cancelled) return; + setResolution({ agentId: null, configured: 'unknown' }); + }); + return () => { + cancelled = true; + }; }, []); - return agentId; + return resolution; } // ── CommentComposer ─────────────────────────────────────────────────────────── @@ -540,7 +590,7 @@ export default function FeedSection() { const [followLoading, setFollowLoading] = useState>({}); const [likeState, setLikeState] = useState>({}); - const myAgentId = useMyAgentId(); + const { agentId: myAgentId, configured: walletConfigured } = useWalletResolution(); // ── Hydrate follow state from the server ─────────────────────────────────── // The home feed doesn't carry "am I following this author?", so seed the @@ -567,7 +617,28 @@ export default function FeedSection() { }, [myAgentId]); // ── Fetch home feed ──────────────────────────────────────────────────────── + // Gate the wallet-requiring `homeFeed()` RPC on wallet status. While wallet + // resolution is still in flight ('resolving') we stay on the loading state + // and fire nothing. When no wallet is configured ('no') we render the + // configure-wallet state WITHOUT calling the RPC — so wallet-less users never + // trip the backend's wallet-not-configured error (prevention at source; the + // boundary classifier remains as defense-in-depth). A configured wallet + // ('yes') — or an inconclusive wallet_status fetch ('unknown') — fires the + // feed fetch as before. useEffect(() => { + if (walletConfigured === 'resolving') { + // Still resolving — stay on the initial loading state, fire nothing yet. + return; + } + if (walletConfigured === 'no') { + // Positive "no wallet" signal — skip the wallet-gated RPC entirely. + log('skipping homeFeed: no wallet configured'); + setFeedState({ status: 'wallet_unconfigured' }); + return; + } + // 'yes' or 'unknown' → fire the feed fetch ('unknown' falls through so the + // backend classifier can handle a wallet-locked error as before). + let cancelled = false; setFeedState({ status: 'loading' }); @@ -590,7 +661,7 @@ export default function FeedSection() { return () => { cancelled = true; }; - }, []); + }, [walletConfigured]); // ── Follow / Unfollow ────────────────────────────────────────────────────── @@ -675,6 +746,14 @@ export default function FeedSection() { Loading feed… ); + } else if (feedState.status === 'wallet_unconfigured') { + body = ( + + ); } else if (feedState.status === 'payment_required') { body = (