From 02f9769bc5c07226a30c0b47f52d2fac3eb6da75 Mon Sep 17 00:00:00 2001
From: oxoxDev <164490987+oxoxDev@users.noreply.github.com>
Date: Wed, 24 Jun 2026 22:36:14 +0530
Subject: [PATCH] fix(tinyplace): gate feed fetch on wallet status so
wallet-less users never hit the RPC (#3964) (#4030)
---
app/src/agentworld/pages/FeedSection.test.tsx | 36 +++++++
app/src/agentworld/pages/FeedSection.tsx | 95 +++++++++++++++++--
2 files changed, 123 insertions(+), 8 deletions(-)
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 = (