From dad34d272f7afe3e0c61e6da5943a73e0b20e7a4 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 28 Jul 2026 11:59:08 +0100 Subject: [PATCH 1/8] fix(watchdog): stop the script dying silently under bash -e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third fix, same root cause each time: I verified in an environment that was not the one it runs in. GitHub runs the step as `bash -e {0}`. Combined with `set -o pipefail` that means any non-zero anywhere kills the script at that line with NO output — a red run and an empty log, which is the least debuggable possible failure. It hit twice: the Actions-API 403, then `grep` returning 1 because a commit touched no mirrored paths. grep exiting 1 on no-match is a normal answer, not an error, and it was the very first loop iteration — so the last run produced literally nothing between the env block and `exit 1`. `set +e` explicitly. Every branch here decides for itself and reports why; `-e` adds nothing but the ability to die mid-check without saying so. Also filter LIVE/TIP/EXPECTED through a 40-hex check. On failure `gh` prints its error body to stdout, which was landing in those vars and being compared as if it were a sha; now a broken call resolves to empty and trips the labelled DEGRADED branch instead. Verified by extracting the step and running it under `bash -e` against the real APIs, both paths: healthy -> "production content is current", exit 0, silent; broken token -> one WATCHDOG DEGRADED line, exit 1. That is the check I should have been running from the first commit. --- .../workflows/content-pipeline-watchdog.yml | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/content-pipeline-watchdog.yml b/.github/workflows/content-pipeline-watchdog.yml index c230a0c79..b3aad9a5e 100644 --- a/.github/workflows/content-pipeline-watchdog.yml +++ b/.github/workflows/content-pipeline-watchdog.yml @@ -49,11 +49,15 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -uo pipefail - - # Every API call is `|| true`: the step shell is `bash -e`, so an - # unguarded 403 aborts mid-script and surfaces as a bare red run - # indistinguishable from a real content alert. That happened once - # (actions:read 403). Collect what we can, then decide explicitly. + # `set +e` is deliberate, and `-e` here is actively harmful. + # GitHub runs this step as `bash -e {0}`, which combined with + # pipefail means ANY non-zero anywhere kills the script at that + # line with no output at all — a red run and an empty log. It bit + # twice: a 403 from the Actions API, then a `grep` with no match + # inside the mirrored-paths filter (grep exits 1 on no-match, which + # is a normal answer, not an error). This script decides everything + # explicitly below; it must never die silently mid-check. + set +e # ---- hop 3: what production is serving ----------------------- LIVE=$(GH_TOKEN=$UI_TOKEN gh api "repos/$REPO/contents/src/content?ref=main" --jq '.sha' || true) @@ -87,6 +91,15 @@ jobs: if [ -n "$PUBLISHED" ]; then EXPECTED="$SHA"; break; fi done + # On failure `gh` prints its error body to stdout, which would land + # in these vars and read as a bogus sha. Keep only real ones, so a + # broken call resolves to empty and trips the DEGRADED branch below + # instead of being compared as if it were data. + only_sha() { printf '%s' "${1:-}" | grep -Eox '[0-9a-f]{40}' || true; } + LIVE=$(only_sha "$LIVE") + TIP=$(only_sha "$TIP") + EXPECTED=$(only_sha "$EXPECTED") + MIRRORED=$(printf '%s' "$TIP_MSG" | sed -nE 's/.*mono@([0-9a-f]+).*/\1/p') echo "expected mono@${EXPECTED:0:7} | mirrored @${MIRRORED:-none} | peanut-content ${TIP:0:7} | live ${LIVE:0:7}" From 458e96483982564308424b9f89df98ba55362b54 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 28 Jul 2026 13:56:25 +0100 Subject: [PATCH 2/8] fix(automerge): pass --admin, and stop swallowing a failed merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The end-to-end test found the real defect. merge-on-green did everything right for #2547 — content-only diff, ci-success 2/2 green — then failed at the last step: Merging #2547 — diff is exactly src/content, ci-success green (2/2) merge failed for #2547 (already merged, or bypass unavailable) Being an OrganizationAdmin does not make `gh pr merge` bypass "Protect Prod". gh enforces the branch policy and requires an explicit `--admin`. I hit this exact error merging #2544 by hand an hour earlier, added the flag, and did not carry it back into the workflow. `--admin` also waives the required status check, so note what it is not waiving: this job has already established for itself that the diff is exactly `src/content` and that every ci-success run on the commit is green. The gate is enforced in code above; --admin only drops the human review, which is the entire purpose of this workflow. A merge we decided to make and then failed to make now fails the run loudly instead of logging a warning and going green. That warning is why the pipeline looked healthy for 40 minutes while Konrad's content sat unpublished — the exact silent failure this work exists to remove. Two more from the same class, found by running the step under `bash -e`: `set +e` with explicit checks, because -e kills the loop mid-flight with no output; and numeric validation of TOTAL/GREEN/PRS, because gh prints its JSON error body to stdout and the loop was iterating over the words of an error message as if they were PR numbers. Verified under `bash -e`: bogus SHA -> "not green (-1/0)", exit 0; real commit with no candidate PR -> "No open content-publish candidates", exit 0. --- .../workflows/content-publish-automerge.yml | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/.github/workflows/content-publish-automerge.yml b/.github/workflows/content-publish-automerge.yml index ecac7aab4..846e63754 100644 --- a/.github/workflows/content-publish-automerge.yml +++ b/.github/workflows/content-publish-automerge.yml @@ -102,7 +102,13 @@ jobs: SHA: ${{ github.event.workflow_run.head_sha }} REPO: ${{ github.repository }} run: | - set -euo pipefail + set -uo pipefail + # `set +e` deliberately. GitHub runs this as `bash -e {0}`, so any + # unguarded non-zero — a transient `gh` 5xx, a PR that closed between + # the list and the diff — kills the step mid-loop with no output and + # a bare red run. Every branch below checks its own inputs and says + # what it decided; dying silently is strictly worse than continuing. + set +e # Key off the `ci-success` check run, NOT `workflow_run.conclusion`. # `ci-success` is the single check the "Protect Prod" ruleset gates @@ -115,8 +121,15 @@ jobs: # carries TWO ci-success check runs. Require at least one and ALL of # them green — a single red run must block the merge. RUNS="repos/$REPO/commits/$SHA/check-runs?per_page=100" - TOTAL=$(gh api "$RUNS" --jq '[.check_runs[] | select(.name=="ci-success")] | length') - GREEN=$(gh api "$RUNS" --jq '[.check_runs[] | select(.name=="ci-success" and .conclusion=="success")] | length') + TOTAL=$(gh api "$RUNS" --jq '[.check_runs[] | select(.name=="ci-success")] | length' || echo 0) + GREEN=$(gh api "$RUNS" --jq '[.check_runs[] | select(.name=="ci-success" and .conclusion=="success")] | length' || echo 0) + # On failure `gh` prints its JSON error body to stdout, which lands + # in these vars. Without this, `[ "$TOTAL" -eq 0 ]` errors out and the + # loop below iterates over the error text as if the words were PR + # numbers. Anything non-numeric means "we don't know" -> don't merge. + case "$TOTAL" in '' | *[!0-9]*) TOTAL=0 ;; esac + case "$GREEN" in '' | *[!0-9]*) GREEN=-1 ;; esac + if [ "$TOTAL" -eq 0 ] || [ "$TOTAL" -ne "$GREEN" ]; then echo "ci-success not green for $SHA ($GREEN/$TOTAL) — nothing to merge." exit 0 @@ -140,12 +153,35 @@ jobs: fi for PR in $PRS; do - FILES=$(gh pr diff "$PR" --repo "$REPO" --name-only) + # Same reason: never treat a fragment of an error body as a PR number. + case "$PR" in '' | *[!0-9]*) echo "::warning::ignoring non-numeric PR id '$PR'"; continue ;; esac + FILES=$(gh pr diff "$PR" --repo "$REPO" --name-only || echo UNKNOWN) if [ "$FILES" != "src/content" ]; then echo "::notice::#$PR is not content-only — normal review gate stays in place." continue fi echo "Merging #$PR — diff is exactly src/content, ci-success green ($GREEN/$TOTAL) on $SHA." - gh pr merge "$PR" --repo "$REPO" --merge \ - || echo "::warning::merge failed for #$PR (already merged, or bypass unavailable) — merge manually." + # `--admin` is required, not optional. Being an OrganizationAdmin + # does NOT make `gh pr merge` bypass "Protect Prod" — gh enforces + # the branch policy and tells you to pass --admin explicitly. + # Without it every merge here fails with "the base branch policy + # prohibits the merge", which is exactly what happened to #2547. + # + # --admin also skips the required status check, so note what it is + # NOT skipping: this job has already established, itself, that the + # diff is exactly src/content and that every ci-success run on this + # commit is green. The gate is enforced above, in code, before we + # get here — --admin only waives the human review, which is the + # whole point of this workflow. + if ! MERGE_ERR=$(gh pr merge "$PR" --repo "$REPO" --merge --admin 2>&1); then + # A merge we decided to make and then failed to make is the exact + # silent failure this pipeline exists to prevent. Be loud. + echo "::error::MERGE FAILED for #$PR — content will NOT reach production. $MERGE_ERR" + FAILED=1 + fi done + + if [ "${FAILED:-0}" = "1" ]; then + echo "At least one content publish could not be merged (see errors above)." + exit 1 + fi From 374a28a38807f055e7207a74a0db979aad6568a0 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Tue, 28 Jul 2026 17:06:37 +0000 Subject: [PATCH 3/8] =?UTF-8?q?content:=20publish=20latest=20to=20producti?= =?UTF-8?q?on=20(src/content=20=E2=86=92=20peanut-content@153d925)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps src/content on production (main) from c268e5d → 153d925 (peanut-content latest = mono@f4a21dc). Single-file submodule pointer change. Publishes content already merged + mirrored from mono that the dev-targeted auto-PRs never promote to main. --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index c268e5d9c..153d925b2 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit c268e5d9c436ae742915b26873e4066748883acc +Subproject commit 153d925b2d239ca901fe65123de826339d1a6265 From 5008b3efb502f47b6abe5b15e227e8b8dffe27d4 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 28 Jul 2026 18:52:00 +0000 Subject: [PATCH 4/8] Update content submodule to latest main --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index 153d925b2..6ad000619 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 153d925b2d239ca901fe65123de826339d1a6265 +Subproject commit 6ad00061928298ea34cf0a76f5a91ef9d1dc2b42 From aa16a83dce1521ae4584aa55e531573796ab4b16 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:08:27 +0530 Subject: [PATCH 5/8] revert(history): restore client-side ENS lookup (reverts #2505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2505 switched history ENS resolution to GET /ens/reverse/:address, but its backend dependency (peanut-api-ts#1237) was never merged or deployed — prod returns 404, so activity history silently fell back to raw addresses for every user. Reverting restores the working JustaName client-side lookup on web. Re-land #2505 once #1237 is live in prod. This reverts commit d5e9789ba1d352ef9aee678de6d31aca15d65483. --- src/components/Global/AddressLink/index.tsx | 8 ++- .../TransactionDetails/TransactionCard.tsx | 8 ++- .../__tests__/TransactionCard.test.tsx | 4 +- .../__tests__/usePrimaryNameServer.test.tsx | 53 ------------------- src/hooks/usePrimaryNameServer.ts | 42 --------------- src/hooks/useRecipientDisplay.ts | 8 ++- 6 files changed, 20 insertions(+), 103 deletions(-) delete mode 100644 src/hooks/__tests__/usePrimaryNameServer.test.tsx delete mode 100644 src/hooks/usePrimaryNameServer.ts diff --git a/src/components/Global/AddressLink/index.tsx b/src/components/Global/AddressLink/index.tsx index bb9d86504..fef51777b 100644 --- a/src/components/Global/AddressLink/index.tsx +++ b/src/components/Global/AddressLink/index.tsx @@ -1,6 +1,6 @@ import { printableAddress, isCryptoAddress } from '@/utils/general.utils' import { normalizeEnsName } from '@/utils/ens.utils' -import { usePrimaryNameServer } from '@/hooks/usePrimaryNameServer' +import { usePrimaryName } from '@justaname.id/react' import Link from 'next/link' import { useEffect, useState } from 'react' import { twMerge } from 'tailwind-merge' @@ -20,7 +20,11 @@ const AddressLink = ({ address, className = '', isLink = true }: AddressLinkProp const [urlAddress, setUrlAddress] = useState(address) // Look up ENS name only for Ethereum addresses (ENS doesn't apply to Solana/Tron) - const { primaryName: ensName } = usePrimaryNameServer(isAddress(address) ? address : undefined) + const { primaryName: ensName } = usePrimaryName({ + address: isAddress(address) ? (address as `0x${string}`) : undefined, + chainId: 1, // Mainnet for ENS lookups + priority: 'onChain', + }) useEffect(() => { const normalizedEnsName = isAddress(address) ? normalizeEnsName(ensName) : null diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index 77914a7fc..50f60355a 100644 --- a/src/components/TransactionDetails/TransactionCard.tsx +++ b/src/components/TransactionDetails/TransactionCard.tsx @@ -23,7 +23,7 @@ import React, { lazy, Suspense } from 'react' import { twMerge } from 'tailwind-merge' import Image from 'next/image' import { isAddress } from 'viem' -import { usePrimaryNameServer } from '@/hooks/usePrimaryNameServer' +import { usePrimaryName } from '@justaname.id/react' import { normalizeEnsName } from '@/utils/ens.utils' import StatusPill, { type StatusPillType } from '../Global/StatusPill' import { VerifiedUserLabel } from '../UserHeader' @@ -105,7 +105,11 @@ const TransactionCard: React.FC = ({ const isTestTransaction = name === 'Enjoy Peanut!' // ENS reverse-lookup for raw addresses; hook is a no-op when name is a username. - const { primaryName } = usePrimaryNameServer(isAddress(name) ? name : undefined) + const { primaryName } = usePrimaryName({ + address: isAddress(name) ? (name as `0x${string}`) : undefined, + chainId: 1, + priority: 'onChain', + }) let displayName = normalizeEnsName(primaryName) ?? name // Shortens crypto addresses AND raw UUIDs (usernameless Peanut users whose // `identifier` arrives as a userId) so the feed row never renders a 36-char diff --git a/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx b/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx index 1801300d2..285d64346 100644 --- a/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx +++ b/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx @@ -38,8 +38,8 @@ jest.mock('@/hooks/useTransactionDetailsDrawer', () => ({ }), })) -jest.mock('@/hooks/usePrimaryNameServer', () => ({ - usePrimaryNameServer: () => ({ primaryName: undefined }), +jest.mock('@justaname.id/react', () => ({ + usePrimaryName: () => ({ primaryName: undefined }), })) jest.mock('@/context/authContext', () => ({ diff --git a/src/hooks/__tests__/usePrimaryNameServer.test.tsx b/src/hooks/__tests__/usePrimaryNameServer.test.tsx deleted file mode 100644 index bf0aad773..000000000 --- a/src/hooks/__tests__/usePrimaryNameServer.test.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import type { ReactNode } from 'react' -import { usePrimaryNameServer } from '../usePrimaryNameServer' -import { serverFetch } from '@/utils/api-fetch' - -jest.mock('@/utils/api-fetch', () => ({ serverFetch: jest.fn() })) - -const mockServerFetch = serverFetch as jest.MockedFunction - -const wrapper = ({ children }: { children: ReactNode }) => { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - return {children} -} - -const ADDRESS = '0x1bbb000000000000000000000000000000ea0349' - -const jsonResponse = (body: unknown, ok = true) => ({ ok, json: async () => body }) as unknown as Response - -beforeEach(() => jest.clearAllMocks()) - -describe('usePrimaryNameServer', () => { - it('resolves the address to its primary name via the backend', async () => { - mockServerFetch.mockResolvedValue(jsonResponse({ name: 'alice.eth' })) - const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) - await waitFor(() => expect(result.current.primaryName).toBe('alice.eth')) - expect(mockServerFetch).toHaveBeenCalledWith(`/ens/reverse/${ADDRESS}`, { method: 'GET' }) - }) - - it('returns undefined when the backend reports no name', async () => { - mockServerFetch.mockResolvedValue(jsonResponse({ name: null })) - const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) - await waitFor(() => expect(mockServerFetch).toHaveBeenCalled()) - expect(result.current.primaryName).toBeUndefined() - }) - - it('fails safe to undefined on a non-OK response (e.g. route not deployed)', async () => { - mockServerFetch.mockResolvedValue(jsonResponse({}, false)) - const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) - await waitFor(() => expect(mockServerFetch).toHaveBeenCalled()) - expect(result.current.primaryName).toBeUndefined() - }) - - it('does not query for a non-address input', () => { - renderHook(() => usePrimaryNameServer('not-an-address'), { wrapper }) - expect(mockServerFetch).not.toHaveBeenCalled() - }) - - it('does not query when address is undefined', () => { - renderHook(() => usePrimaryNameServer(undefined), { wrapper }) - expect(mockServerFetch).not.toHaveBeenCalled() - }) -}) diff --git a/src/hooks/usePrimaryNameServer.ts b/src/hooks/usePrimaryNameServer.ts deleted file mode 100644 index cc5c1d718..000000000 --- a/src/hooks/usePrimaryNameServer.ts +++ /dev/null @@ -1,42 +0,0 @@ -'use client' - -import { useQuery } from '@tanstack/react-query' -import { isAddress } from 'viem' -import { serverFetch } from '@/utils/api-fetch' - -const PRIMARY_NAME_TTL_MS = 24 * 60 * 60 * 1000 // 1 day - -/** - * ENS reverse-lookup (address → primary name) resolved SERVER-SIDE via the - * backend `/ens/reverse` endpoint, instead of a client call to the mainnet RPC. - * - * Why: on native the WebView origin (`capacitor://localhost` / `https://localhost`) - * is rejected by the RPC's CORS policy, so the client-side `usePrimaryName` - * (JustaName, `priority: 'onChain'`) always failed on mobile and the feed fell - * back to the raw address. The backend has no such origin restriction and is - * already CORS-allowed for native, so this resolves identically on web and - * native. Drop-in for `@justaname.id/react`'s `usePrimaryName` — same - * `{ primaryName }` shape. - * - * Fail-safe: any non-OK response (incl. a backend that doesn't yet expose the - * route) yields `undefined`, so callers degrade to the address exactly as before. - */ -export function usePrimaryNameServer(address?: string): { primaryName: string | undefined } { - const enabled = !!address && isAddress(address) - - const { data } = useQuery({ - queryKey: ['ens-primary-name', address?.toLowerCase()], - enabled, - staleTime: PRIMARY_NAME_TTL_MS, - gcTime: PRIMARY_NAME_TTL_MS, - retry: false, - queryFn: async (): Promise => { - const res = await serverFetch(`/ens/reverse/${address}`, { method: 'GET' }) - if (!res.ok) return null - const json = (await res.json()) as { name?: string | null } - return json.name ?? null - }, - }) - - return { primaryName: data ?? undefined } -} diff --git a/src/hooks/useRecipientDisplay.ts b/src/hooks/useRecipientDisplay.ts index 706954d91..7fbb1a988 100644 --- a/src/hooks/useRecipientDisplay.ts +++ b/src/hooks/useRecipientDisplay.ts @@ -1,8 +1,8 @@ 'use client' +import { usePrimaryName } from '@justaname.id/react' import { useMemo } from 'react' import { isAddress } from 'viem' -import { usePrimaryNameServer } from '@/hooks/usePrimaryNameServer' import { resolveRecipientDisplay, type RecipientDisplay, type ResolveRecipientInput } from '@/utils/recipient-display' import { normalizeEnsName } from '@/utils/ens.utils' @@ -20,7 +20,11 @@ import { normalizeEnsName } from '@/utils/ens.utils' export function useRecipientDisplay(input: Omit): RecipientDisplay { const skipEnsLookup = !!input.user?.username || !isAddress(input.address) - const { primaryName } = usePrimaryNameServer(skipEnsLookup ? undefined : input.address) + const { primaryName } = usePrimaryName({ + address: skipEnsLookup ? undefined : (input.address as `0x${string}`), + chainId: 1, + priority: 'onChain', + }) return useMemo( () => From 8705b6e8b9f31306ad695a1eadf3eaff7b50a86e Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:33:31 +0530 Subject: [PATCH 6/8] Revert "revert(history): restore client-side ENS lookup (reverts #2505)" This reverts commit aa16a83dce1521ae4584aa55e531573796ab4b16. --- src/components/Global/AddressLink/index.tsx | 8 +-- .../TransactionDetails/TransactionCard.tsx | 8 +-- .../__tests__/TransactionCard.test.tsx | 4 +- .../__tests__/usePrimaryNameServer.test.tsx | 53 +++++++++++++++++++ src/hooks/usePrimaryNameServer.ts | 42 +++++++++++++++ src/hooks/useRecipientDisplay.ts | 8 +-- 6 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 src/hooks/__tests__/usePrimaryNameServer.test.tsx create mode 100644 src/hooks/usePrimaryNameServer.ts diff --git a/src/components/Global/AddressLink/index.tsx b/src/components/Global/AddressLink/index.tsx index fef51777b..bb9d86504 100644 --- a/src/components/Global/AddressLink/index.tsx +++ b/src/components/Global/AddressLink/index.tsx @@ -1,6 +1,6 @@ import { printableAddress, isCryptoAddress } from '@/utils/general.utils' import { normalizeEnsName } from '@/utils/ens.utils' -import { usePrimaryName } from '@justaname.id/react' +import { usePrimaryNameServer } from '@/hooks/usePrimaryNameServer' import Link from 'next/link' import { useEffect, useState } from 'react' import { twMerge } from 'tailwind-merge' @@ -20,11 +20,7 @@ const AddressLink = ({ address, className = '', isLink = true }: AddressLinkProp const [urlAddress, setUrlAddress] = useState(address) // Look up ENS name only for Ethereum addresses (ENS doesn't apply to Solana/Tron) - const { primaryName: ensName } = usePrimaryName({ - address: isAddress(address) ? (address as `0x${string}`) : undefined, - chainId: 1, // Mainnet for ENS lookups - priority: 'onChain', - }) + const { primaryName: ensName } = usePrimaryNameServer(isAddress(address) ? address : undefined) useEffect(() => { const normalizedEnsName = isAddress(address) ? normalizeEnsName(ensName) : null diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index 50f60355a..77914a7fc 100644 --- a/src/components/TransactionDetails/TransactionCard.tsx +++ b/src/components/TransactionDetails/TransactionCard.tsx @@ -23,7 +23,7 @@ import React, { lazy, Suspense } from 'react' import { twMerge } from 'tailwind-merge' import Image from 'next/image' import { isAddress } from 'viem' -import { usePrimaryName } from '@justaname.id/react' +import { usePrimaryNameServer } from '@/hooks/usePrimaryNameServer' import { normalizeEnsName } from '@/utils/ens.utils' import StatusPill, { type StatusPillType } from '../Global/StatusPill' import { VerifiedUserLabel } from '../UserHeader' @@ -105,11 +105,7 @@ const TransactionCard: React.FC = ({ const isTestTransaction = name === 'Enjoy Peanut!' // ENS reverse-lookup for raw addresses; hook is a no-op when name is a username. - const { primaryName } = usePrimaryName({ - address: isAddress(name) ? (name as `0x${string}`) : undefined, - chainId: 1, - priority: 'onChain', - }) + const { primaryName } = usePrimaryNameServer(isAddress(name) ? name : undefined) let displayName = normalizeEnsName(primaryName) ?? name // Shortens crypto addresses AND raw UUIDs (usernameless Peanut users whose // `identifier` arrives as a userId) so the feed row never renders a 36-char diff --git a/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx b/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx index 285d64346..1801300d2 100644 --- a/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx +++ b/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx @@ -38,8 +38,8 @@ jest.mock('@/hooks/useTransactionDetailsDrawer', () => ({ }), })) -jest.mock('@justaname.id/react', () => ({ - usePrimaryName: () => ({ primaryName: undefined }), +jest.mock('@/hooks/usePrimaryNameServer', () => ({ + usePrimaryNameServer: () => ({ primaryName: undefined }), })) jest.mock('@/context/authContext', () => ({ diff --git a/src/hooks/__tests__/usePrimaryNameServer.test.tsx b/src/hooks/__tests__/usePrimaryNameServer.test.tsx new file mode 100644 index 000000000..bf0aad773 --- /dev/null +++ b/src/hooks/__tests__/usePrimaryNameServer.test.tsx @@ -0,0 +1,53 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { usePrimaryNameServer } from '../usePrimaryNameServer' +import { serverFetch } from '@/utils/api-fetch' + +jest.mock('@/utils/api-fetch', () => ({ serverFetch: jest.fn() })) + +const mockServerFetch = serverFetch as jest.MockedFunction + +const wrapper = ({ children }: { children: ReactNode }) => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return {children} +} + +const ADDRESS = '0x1bbb000000000000000000000000000000ea0349' + +const jsonResponse = (body: unknown, ok = true) => ({ ok, json: async () => body }) as unknown as Response + +beforeEach(() => jest.clearAllMocks()) + +describe('usePrimaryNameServer', () => { + it('resolves the address to its primary name via the backend', async () => { + mockServerFetch.mockResolvedValue(jsonResponse({ name: 'alice.eth' })) + const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) + await waitFor(() => expect(result.current.primaryName).toBe('alice.eth')) + expect(mockServerFetch).toHaveBeenCalledWith(`/ens/reverse/${ADDRESS}`, { method: 'GET' }) + }) + + it('returns undefined when the backend reports no name', async () => { + mockServerFetch.mockResolvedValue(jsonResponse({ name: null })) + const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) + await waitFor(() => expect(mockServerFetch).toHaveBeenCalled()) + expect(result.current.primaryName).toBeUndefined() + }) + + it('fails safe to undefined on a non-OK response (e.g. route not deployed)', async () => { + mockServerFetch.mockResolvedValue(jsonResponse({}, false)) + const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) + await waitFor(() => expect(mockServerFetch).toHaveBeenCalled()) + expect(result.current.primaryName).toBeUndefined() + }) + + it('does not query for a non-address input', () => { + renderHook(() => usePrimaryNameServer('not-an-address'), { wrapper }) + expect(mockServerFetch).not.toHaveBeenCalled() + }) + + it('does not query when address is undefined', () => { + renderHook(() => usePrimaryNameServer(undefined), { wrapper }) + expect(mockServerFetch).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/usePrimaryNameServer.ts b/src/hooks/usePrimaryNameServer.ts new file mode 100644 index 000000000..cc5c1d718 --- /dev/null +++ b/src/hooks/usePrimaryNameServer.ts @@ -0,0 +1,42 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { isAddress } from 'viem' +import { serverFetch } from '@/utils/api-fetch' + +const PRIMARY_NAME_TTL_MS = 24 * 60 * 60 * 1000 // 1 day + +/** + * ENS reverse-lookup (address → primary name) resolved SERVER-SIDE via the + * backend `/ens/reverse` endpoint, instead of a client call to the mainnet RPC. + * + * Why: on native the WebView origin (`capacitor://localhost` / `https://localhost`) + * is rejected by the RPC's CORS policy, so the client-side `usePrimaryName` + * (JustaName, `priority: 'onChain'`) always failed on mobile and the feed fell + * back to the raw address. The backend has no such origin restriction and is + * already CORS-allowed for native, so this resolves identically on web and + * native. Drop-in for `@justaname.id/react`'s `usePrimaryName` — same + * `{ primaryName }` shape. + * + * Fail-safe: any non-OK response (incl. a backend that doesn't yet expose the + * route) yields `undefined`, so callers degrade to the address exactly as before. + */ +export function usePrimaryNameServer(address?: string): { primaryName: string | undefined } { + const enabled = !!address && isAddress(address) + + const { data } = useQuery({ + queryKey: ['ens-primary-name', address?.toLowerCase()], + enabled, + staleTime: PRIMARY_NAME_TTL_MS, + gcTime: PRIMARY_NAME_TTL_MS, + retry: false, + queryFn: async (): Promise => { + const res = await serverFetch(`/ens/reverse/${address}`, { method: 'GET' }) + if (!res.ok) return null + const json = (await res.json()) as { name?: string | null } + return json.name ?? null + }, + }) + + return { primaryName: data ?? undefined } +} diff --git a/src/hooks/useRecipientDisplay.ts b/src/hooks/useRecipientDisplay.ts index 7fbb1a988..706954d91 100644 --- a/src/hooks/useRecipientDisplay.ts +++ b/src/hooks/useRecipientDisplay.ts @@ -1,8 +1,8 @@ 'use client' -import { usePrimaryName } from '@justaname.id/react' import { useMemo } from 'react' import { isAddress } from 'viem' +import { usePrimaryNameServer } from '@/hooks/usePrimaryNameServer' import { resolveRecipientDisplay, type RecipientDisplay, type ResolveRecipientInput } from '@/utils/recipient-display' import { normalizeEnsName } from '@/utils/ens.utils' @@ -20,11 +20,7 @@ import { normalizeEnsName } from '@/utils/ens.utils' export function useRecipientDisplay(input: Omit): RecipientDisplay { const skipEnsLookup = !!input.user?.username || !isAddress(input.address) - const { primaryName } = usePrimaryName({ - address: skipEnsLookup ? undefined : (input.address as `0x${string}`), - chainId: 1, - priority: 'onChain', - }) + const { primaryName } = usePrimaryNameServer(skipEnsLookup ? undefined : input.address) return useMemo( () => From a77f68d1395a4166344b1b4efe509eed43e3796b Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:37:07 +0530 Subject: [PATCH 7/8] fix(ens): fall back to client-side lookup when /ens/reverse is unavailable the server-first hook shipped in #2505 before its backend endpoint (peanut-api-ts#1237) deployed, so prod 404s every lookup and history shows raw addresses. instead of reverting, keep server-first (needed for native, where the mainnet RPC blocks WebView origins via CORS) and engage JustaName's client-side lookup only when the server call fails. web gets ENS names back immediately; native picks up the server path automatically once #1237 deploys, with no frontend re-land. non-OK now throws instead of caching a negative result for 24h. --- .../__tests__/usePrimaryNameServer.test.tsx | 24 +++++++++-- src/hooks/usePrimaryNameServer.ts | 42 ++++++++++++------- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/hooks/__tests__/usePrimaryNameServer.test.tsx b/src/hooks/__tests__/usePrimaryNameServer.test.tsx index bf0aad773..1fb50f6ce 100644 --- a/src/hooks/__tests__/usePrimaryNameServer.test.tsx +++ b/src/hooks/__tests__/usePrimaryNameServer.test.tsx @@ -1,12 +1,15 @@ import { renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { ReactNode } from 'react' +import { usePrimaryName } from '@justaname.id/react' import { usePrimaryNameServer } from '../usePrimaryNameServer' import { serverFetch } from '@/utils/api-fetch' jest.mock('@/utils/api-fetch', () => ({ serverFetch: jest.fn() })) const mockServerFetch = serverFetch as jest.MockedFunction +// resolves to the shared manual mock via jest moduleNameMapper +const mockUsePrimaryName = usePrimaryName as jest.Mock const wrapper = ({ children }: { children: ReactNode }) => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) @@ -17,7 +20,10 @@ const ADDRESS = '0x1bbb000000000000000000000000000000ea0349' const jsonResponse = (body: unknown, ok = true) => ({ ok, json: async () => body }) as unknown as Response -beforeEach(() => jest.clearAllMocks()) +beforeEach(() => { + jest.clearAllMocks() + mockUsePrimaryName.mockReturnValue({ primaryName: undefined, isLoading: false, error: null }) +}) describe('usePrimaryNameServer', () => { it('resolves the address to its primary name via the backend', async () => { @@ -25,6 +31,8 @@ describe('usePrimaryNameServer', () => { const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) await waitFor(() => expect(result.current.primaryName).toBe('alice.eth')) expect(mockServerFetch).toHaveBeenCalledWith(`/ens/reverse/${ADDRESS}`, { method: 'GET' }) + // client fallback stays a no-op while the server path works + expect(mockUsePrimaryName).toHaveBeenLastCalledWith(expect.objectContaining({ address: undefined })) }) it('returns undefined when the backend reports no name', async () => { @@ -34,10 +42,20 @@ describe('usePrimaryNameServer', () => { expect(result.current.primaryName).toBeUndefined() }) - it('fails safe to undefined on a non-OK response (e.g. route not deployed)', async () => { + it('falls back to the client-side lookup on a non-OK response (e.g. route not deployed)', async () => { mockServerFetch.mockResolvedValue(jsonResponse({}, false)) + mockUsePrimaryName.mockReturnValue({ primaryName: 'alice.eth', isLoading: false, error: null }) const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) - await waitFor(() => expect(mockServerFetch).toHaveBeenCalled()) + await waitFor(() => expect(result.current.primaryName).toBe('alice.eth')) + expect(mockUsePrimaryName).toHaveBeenLastCalledWith(expect.objectContaining({ address: ADDRESS })) + }) + + it('fails safe to undefined when both server and client lookups fail', async () => { + mockServerFetch.mockResolvedValue(jsonResponse({}, false)) + const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) + await waitFor(() => + expect(mockUsePrimaryName).toHaveBeenLastCalledWith(expect.objectContaining({ address: ADDRESS })) + ) expect(result.current.primaryName).toBeUndefined() }) diff --git a/src/hooks/usePrimaryNameServer.ts b/src/hooks/usePrimaryNameServer.ts index cc5c1d718..ec93fec9b 100644 --- a/src/hooks/usePrimaryNameServer.ts +++ b/src/hooks/usePrimaryNameServer.ts @@ -1,5 +1,6 @@ 'use client' +import { usePrimaryName } from '@justaname.id/react' import { useQuery } from '@tanstack/react-query' import { isAddress } from 'viem' import { serverFetch } from '@/utils/api-fetch' @@ -7,36 +8,49 @@ import { serverFetch } from '@/utils/api-fetch' const PRIMARY_NAME_TTL_MS = 24 * 60 * 60 * 1000 // 1 day /** - * ENS reverse-lookup (address → primary name) resolved SERVER-SIDE via the - * backend `/ens/reverse` endpoint, instead of a client call to the mainnet RPC. + * ENS reverse-lookup (address → primary name), server-first with a client-side + * fallback. * - * Why: on native the WebView origin (`capacitor://localhost` / `https://localhost`) - * is rejected by the RPC's CORS policy, so the client-side `usePrimaryName` - * (JustaName, `priority: 'onChain'`) always failed on mobile and the feed fell - * back to the raw address. The backend has no such origin restriction and is - * already CORS-allowed for native, so this resolves identically on web and - * native. Drop-in for `@justaname.id/react`'s `usePrimaryName` — same - * `{ primaryName }` shape. + * Server-first: the backend `/ens/reverse` endpoint works on native, where the + * WebView origin (`capacitor://localhost` / `https://localhost`) is rejected by + * the mainnet RPC's CORS policy and a client lookup can never succeed. * - * Fail-safe: any non-OK response (incl. a backend that doesn't yet expose the - * route) yields `undefined`, so callers degrade to the address exactly as before. + * Client fallback: the endpoint is not deployed everywhere yet (peanut-api-ts + * #1237). When the server call fails, fall back to JustaName's client-side + * on-chain lookup — this keeps ENS names working on web today, and native + * picks up the server path automatically the moment the endpoint ships, + * with no frontend change. + * + * Drop-in for `@justaname.id/react`'s `usePrimaryName` — same + * `{ primaryName }` shape. If both paths fail, callers degrade to the raw + * address exactly as before. */ export function usePrimaryNameServer(address?: string): { primaryName: string | undefined } { const enabled = !!address && isAddress(address) - const { data } = useQuery({ + const { data, isError } = useQuery({ queryKey: ['ens-primary-name', address?.toLowerCase()], enabled, staleTime: PRIMARY_NAME_TTL_MS, gcTime: PRIMARY_NAME_TTL_MS, retry: false, queryFn: async (): Promise => { + // throw on non-OK so the client fallback below engages, instead of + // caching a negative result for 24h while the route 404s const res = await serverFetch(`/ens/reverse/${address}`, { method: 'GET' }) - if (!res.ok) return null + if (!res.ok) throw new Error(`ens reverse lookup failed: ${res.status}`) const json = (await res.json()) as { name?: string | null } return json.name ?? null }, }) - return { primaryName: data ?? undefined } + // fallback lookup, only engaged when the server call failed. address stays + // undefined otherwise, which makes the hook a no-op. + const { primaryName: clientName } = usePrimaryName({ + address: enabled && isError ? (address as `0x${string}`) : undefined, + chainId: 1, // mainnet for ens lookups + priority: 'onChain', + }) + + return { primaryName: data ?? (isError ? clientName : undefined) } } From 7525e87e599c4f87689b9f2469fef7e1d979b27b Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:44:44 +0530 Subject: [PATCH 8/8] test(ens): synchronize null-name assertion on query settle, not fetch call coderabbit: the previous waitFor only observed the fetch being fired, so the undefined assertion could pass before the query resolved. wait for the post-resolve re-render and assert the client fallback stays disabled when the server answers authoritatively. --- src/hooks/__tests__/usePrimaryNameServer.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/hooks/__tests__/usePrimaryNameServer.test.tsx b/src/hooks/__tests__/usePrimaryNameServer.test.tsx index 1fb50f6ce..ce148a5f5 100644 --- a/src/hooks/__tests__/usePrimaryNameServer.test.tsx +++ b/src/hooks/__tests__/usePrimaryNameServer.test.tsx @@ -36,10 +36,14 @@ describe('usePrimaryNameServer', () => { }) it('returns undefined when the backend reports no name', async () => { - mockServerFetch.mockResolvedValue(jsonResponse({ name: null })) + const json = jest.fn(async () => ({ name: null })) + mockServerFetch.mockResolvedValue({ ok: true, json } as unknown as Response) const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) - await waitFor(() => expect(mockServerFetch).toHaveBeenCalled()) + // wait for the resolved-null query to re-render the hook, not just for the fetch call + await waitFor(() => expect(mockUsePrimaryName.mock.calls.length).toBeGreaterThan(1)) expect(result.current.primaryName).toBeUndefined() + // a live endpoint answering "no name" is authoritative — client fallback stays disabled + expect(mockUsePrimaryName).toHaveBeenLastCalledWith(expect.objectContaining({ address: undefined })) }) it('falls back to the client-side lookup on a non-OK response (e.g. route not deployed)', async () => {