Skip to content

Sync: Merge prod invite graph changes to dev#1645

Merged
Hugo0 merged 23 commits intopeanut-wallet-devfrom
sync/prod-to-dev
Jan 27, 2026
Merged

Sync: Merge prod invite graph changes to dev#1645
Hugo0 merged 23 commits intopeanut-wallet-devfrom
sync/prod-to-dev

Conversation

@Hugo0
Copy link
Contributor

@Hugo0 Hugo0 commented Jan 27, 2026

Summary

Merges invite graph enhancements from peanut-wallet (prod) into peanut-wallet-dev.

Changes from prod:

  • Payment mode with anonymized data and password auth
  • Full graph mode with API key auth and user whitelist
  • External nodes (banks, wallets, merchants) visualization
  • Activity filtering with progressive inactive fade
  • Logarithmic edge animations based on tx volume
  • Search for external nodes with custom labels
  • Top nodes slider for backend-filtered performance

Conflict resolution:

  • Kept types in separate types.ts file (dev's approach)
  • Added payment mode fields to types (prod's enhancement)
  • Used prod's backend topNodes filtering (replaces dev's client-side pruneGraphData)

Test plan

  • Verify full-graph page works with API key auth
  • Verify payment-graph page works with password auth
  • Test external nodes toggle
  • Test activity filtering

Hugo0 and others added 22 commits January 20, 2026 13:48
- Restored "new users" feature: green nodes for recent signups (within activity window)
- Three-state activity status: New (green), Active (purple), Inactive (gray)
- Progressive inactive fade with exponential time bands (1w, 2w, 4w, 8w, 16w, 32w, 64w+)
- Each band gets progressively lighter gray + lower opacity for visual hierarchy
- Updated legend to include "New" status
- Fixed Grafana link format (teampeanut.grafana.net/explore-peanut-wallet-user)
- Fix external edge tooltips: show per-user amounts instead of "undefined - Invalid Date"
- Display accurate transaction counts/amounts per user-external node pair
- Update ExternalNode type to include userTxData for edge weight calculation
- Change minConnections from slider to discrete buttons (1, 2, 3, 5, 10)
- Lower default minConnections from 2 to 1 to show all external nodes
- Add fallback logic for missing userTxData

Now edges show "External: 5 txs ($200.50)" with correct per-user data
instead of showing node totals on every edge.
- Search now includes external nodes (banks, wallets, merchants)
- Searches both custom labels ("Manteca BR Deposit") and original IDs
- Search results show emoji indicators for external node types (🏦 🏪 💳)
- External results show user count and total USD instead of points
- Allow right-click selection of external nodes for camera zoom
- "Focused on" banner shows custom labels for external nodes with orange styling
- Results styled with orange hover for external nodes vs purple for users

Users can now search for "Manteca", "Bridge SEPA", bank IDs, etc.
Activity Time Window Filtering:
- Filter external nodes by activityDays (e.g., 30d filter hides old merchants)
- Add lastTxDate to ExternalNode type for client-side filtering
- External nodes now respect same time window as user nodes
- Fixes bug: devcon-swag-shop (last tx Nov 21) no longer shows with 30d filter

Logarithmic Edge Animation Scaling:
- OLD: Linear scaling made 9 txs vs 32 txs barely distinguishable
- NEW: Log10 scaling for dramatic visual distinction between activity levels

P2P Edge Scaling (logarithmic):
- Line width: 0.4px → 3.0px (7.5x variance)
- Particle speed: 0.0003 → 0.001 (3.3x variance, log-scaled by tx count)
- Particle size: 1.5px → 6.0px (4x variance, log-scaled by USD volume)
- Particle count: 1 → 5 particles (log-scaled)

External Edge Scaling (now matching P2P):
- Line width: 0.4px → 3.0px (same formula as P2P)
- Particle speed: 0.0002 → 0.0008 (70% of P2P max for visual distinction)
- Particle size: 1.5px → 6.0px (same 4x variance, log-scaled by USD)
- Particle count: 1 → 4 particles (one less than P2P to reduce clutter)

Visual Impact Examples:
- 1 tx: slow, tiny, single particle (barely visible)
- 9 txs: 2.2x faster, 2 particles, medium (clearly visible)
- 32 txs: 2.8x faster, 3 particles, larger (dramatically different)
- 100 txs: 3.3x max speed, 5 particles, huge (very obvious)

External Node Tooltips:
- Simplified to show only formatted label (no full account numbers)
- Bank accounts: "🏷️ Account: ES27 **** 7890"
- Wallets/Merchants: "🏷️ ID: devcon-swag-shop"

Code Cleanup:
- Removed all console.log statements (migration logs, force logs, recalc debug)
- Cleaner console output for production

Performance: Logarithmic calculations add negligible overhead (<0.01ms per frame).
- Add `/dev/payment-graph` page with anonymized P2P-only visualization
- Add `/dev/layout.tsx` for dev-only route protection (except full-graph and payment-graph)
- Rename invite-graph to full-graph
- Replace "All nodes" checkbox with "Top nodes" slider (0-10000, backend-filtered)
- Remove timestamps from payment mode response for full anonymization
- Remove unused `showAllNodes` prop, replace with `topNodes` (number)
- Payment mode defaults: merchants ON, minConnections=10, 5000 node limit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…chant issue

Cleaned up all debug console.log statements that were added to investigate the duplicate node ID issue. The root cause was identified and fixed in the backend (SHA-256 hash collision prevention).

Changes:
- Removed API response duplicate detection logging
- Removed external links duplicate ID checking
- Removed orphan merchant warning logs
- Removed link creation summary logs
- Cleaned up unused variables (txDataKeys, matchedLinks, unmatchedKeys)
- Remove verbose debug logs from graph rendering
- Add lightweight console.assert() for duplicate ID detection
- Remove unused simulation variable
- Clean up debug logging throughout InvitesGraph component
- Add safety nets for duplicate node/external node IDs

This reduces console noise in production while maintaining
runtime safety checks that only appear when assertions fail.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Add SizeLabel export for node sizing in payment mode
- Make node fields optional to support both full and payment modes
- Add separate localStorage key for payment graph preferences
- Update payment graph to use topNodes=5000 with performanceMode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…op-nodes-slider

feat(graph): Add payment-graph mode and top-nodes slider for full-graph
- Only update fullName if it's missing (not overwrite existing)
- Only update email if it's missing (not overwrite existing)
- Add proper error handling for fetchUser() call
- Trim email field like accountOwnerName
- Only reset error when modal opens (not when closing)
hot-fix: update user details if missing during KYC process and reset error state on modal open
- Replace API key auth with password query param for payment mode
- Read password from URL (?password=xxx) or show input form
- Support direct URL access: /dev/payment-graph?password=xxx
- Backend validates password against PAYMENT_GRAPH_PASSWORD env var
- API key still required for full-graph mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…op-nodes-slider

feat: payment graph with password auth and UI improvements
The external nodes API call was missing password param and was still
sending API key header for payment mode. This caused 401 errors when
loading external nodes in payment-graph.

- Add password option to getExternalNodes
- Pass password in query params for payment mode
- Skip api-key header for payment mode (uses password auth)
- Pass password from InvitesGraph component to API call

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Username was 'konrad' but should be 'kkonrad'.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Brings invite graph enhancements from prod to dev:
- Payment mode with anonymized data and password auth
- Full graph mode with API key auth and user whitelist
- External nodes (banks, wallets, merchants) visualization
- Activity filtering with progressive inactive fade
- Logarithmic edge animations based on tx volume
- Search for external nodes with custom labels
- Top nodes slider for backend-filtered performance

Resolves conflicts in InvitesGraph by:
- Keeping types in separate types.ts file (dev approach)
- Adding payment mode fields to types (prod enhancement)
- Using prod's backend topNodes filtering (replaces dev's client-side pruneGraphData)
@vercel
Copy link

vercel bot commented Jan 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jan 27, 2026 11:53am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 27, 2026

Caution

Review failed

The pull request is closed.

Walkthrough

Introduces multi-mode graph support (full/payment/user) with mode-aware APIs and types, replaces showAllNodes with topNodes limits, adds guarded dev pages (FullGraphPage, password-gated PaymentGraphPage) and a DevLayout guard, and applies maintenance-driven behavior to the TokenSelector.

Changes

Cohort / File(s) Summary
Dev pages & access control
src/app/(mobile-ui)/dev/full-graph/page.tsx, src/app/(mobile-ui)/dev/payment-graph/page.tsx, src/app/(mobile-ui)/dev/layout.tsx, src/app/(mobile-ui)/dev/page.tsx
Rename InviteGraphPage → FullGraphPage; add ALLOWED_USERNAMES/frontend access checks and a DevLayout that blocks non-dev production paths; add password-gated PaymentGraphPage with large force/config overlays, performanceMode, and new topNodes/minConnections controls.
Graph component & types
src/components/Global/InvitesGraph/index.tsx, src/components/Global/InvitesGraph/types.ts
Replace showAllNodes with numeric topNodes (0 = all, DEFAULT_TOP_NODES=5000); add GraphMode ('full'
Services / API changes
src/services/points.ts
Add qualitative label types and P2PEdge; make node/edge/external fields optional; add UserTxDataEntry and External direction; change fetchInvitesGraph/pointsApi.getInvitesGraph/getExternalNodes signatures to accept mode/topNodes/password and conditional auth headers.
Preferences hook
src/hooks/useGraphPreferences.ts
Add mode parameter (`'full'
Pages integration
src/app/(mobile-ui)/points/page.tsx
Gate invites graph rendering by IS_DEV or SEEDLING badge; render InvitesGraph as its own section with explanatory text varying by dev/badge access.
Routes & public access
src/constants/routes.ts
Add dev/payment-graph to PUBLIC_ROUTES and update PUBLIC_ROUTES_REGEX and comments to reflect exception.
Token selector & maintenance config
src/components/Global/TokenSelector/TokenSelector.tsx, src/config/underMaintenance.config.ts
Add disableSquidWithdraw flag to maintenance config; when set, restrict cross-chain withdraw options (only USDC on Arbitrum), hide network/search UI, show info banner, and adjust token lists/headers.
Miscellaneous UI/UX
src/app/(mobile-ui)/dev/full-graph/page.tsx (UI controls), .../payment-graph/page.tsx (controls overlay)
Replace previous Show All Nodes toggle with Top nodes slider (0–10000, step 500) and discrete minConnections buttons (1,2,3,5,10,20,50); add node status legend and updated conditional display text.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Sync: Merge prod invite graph changes to dev' accurately summarizes the main objective: synchronizing changes from production into the dev branch.
Description check ✅ Passed The description relates directly to the changeset, explaining the source (prod), key features added, and conflict resolution approach with a test plan.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch sync/prod-to-dev

Comment @coderabbitai help to get the list of available commands and usage tips.

@Hugo0 Hugo0 merged commit 8d0e1bc into peanut-wallet-dev Jan 27, 2026
3 of 4 checks passed
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/app/(mobile-ui)/dev/page.tsx (1)

48-51: Align the intro text with dev-only availability.

The info card now says “only available in development mode,” but the intro still says “Publicly accessible,” which is confusing.

✏️ Suggested copy update
-                        Internal testing tools and components. Publicly accessible for multi-device testing.
+                        Internal testing tools and components. Available only in development mode.

Also applies to: 81-81

src/components/Global/TokenSelector/TokenSelector.tsx (1)

36-65: Force selection to USDC/Arbitrum when cross-chain withdraw is disabled.

When maintenance mode activates, the UI hides non-USDC/Arbitrum options, but an already-selected token/chain persists in context state. This creates a mismatch: the button shows "USDT on Base" while the drawer only offers "USDC on Arbitrum". Add a useEffect to reset the selection to USDC/Arbitrum when disableSquidWithdraw becomes true, ensuring consistency between button display and available options.

🛠️ Suggested guard (auto-reset selection)
-import React, { type ReactNode, useCallback, useContext, useMemo, useRef, useState } from 'react'
+import React, { type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
@@
 const TokenSelector: React.FC<NewTokenSelectorProps> = ({ classNameButton, viewType = 'other', disabled }) => {
     // check if cross-chain withdraw is disabled via maintenance config
     const isSquidWithdrawDisabled = viewType === 'withdraw' && underMaintenanceConfig.disableSquidWithdraw
@@
     const {
         supportedSquidChainsAndTokens,
         setSelectedTokenAddress,
         setSelectedChainID,
         selectedTokenAddress,
         selectedChainID,
     } = useContext(tokenSelectorContext)
+
+    useEffect(() => {
+        if (!isSquidWithdrawDisabled) return
+        const arbitrumChainId = PEANUT_WALLET_CHAIN.id.toString()
+        const chainData = supportedSquidChainsAndTokens?.[arbitrumChainId]
+        const usdcToken = chainData?.tokens?.find((t) => areEvmAddressesEqual(t.address, PEANUT_WALLET_TOKEN))
+        if (!usdcToken) return
+
+        const isAlreadyUSDC =
+            selectedChainID === arbitrumChainId &&
+            selectedTokenAddress &&
+            areEvmAddressesEqual(selectedTokenAddress, usdcToken.address)
+        if (!isAlreadyUSDC) {
+            setSelectedChainID(arbitrumChainId)
+            setSelectedTokenAddress(usdcToken.address)
+        }
+    }, [
+        isSquidWithdrawDisabled,
+        supportedSquidChainsAndTokens,
+        selectedChainID,
+        selectedTokenAddress,
+        setSelectedChainID,
+        setSelectedTokenAddress,
+    ])
src/components/Global/InvitesGraph/index.tsx (2)

1-3: Fix Prettier formatting for this file (CI warning).
Run pnpm prettier --write src/components/Global/InvitesGraph/index.tsx to satisfy the pipeline check.


1497-1505: Defaults reset should also restore topNodes.
“Reset all settings to defaults” currently leaves the top‑N limit unchanged.

🔧 Suggested reset behavior
         setVisibilityConfig(DEFAULT_VISIBILITY_CONFIG)
         setExternalNodesConfig(DEFAULT_EXTERNAL_NODES_CONFIG)
+        setTopNodes(initialTopNodes)
         // Reset camera
         graphRef.current?.zoomToFit(400)
🤖 Fix all issues with AI agents
In `@src/app/`(mobile-ui)/dev/layout.tsx:
- Around line 1-23: DevLayout currently calls notFound() from a Client Component
(using usePathname), which is unsupported; remove the notFound() call and move
the production-route guard into a parent Server Component (or create a
server-side wrapper) that checks IS_DEV and PRODUCTION_ALLOWED_ROUTES and
returns a proper 404 there, or if you must stay client-side implement a
client-side redirect on mount instead of notFound; also tighten the route check
in PRODUCTION_ALLOWED_ROUTES logic (use exact match or ensure the path segment
boundary when checking startsWith to avoid matching prefixes like
"/dev/full-graphx"). Reference: DevLayout, usePathname, notFound, IS_DEV,
PRODUCTION_ALLOWED_ROUTES.

In `@src/components/Global/InvitesGraph/index.tsx`:
- Around line 2317-2331: The external label currently always uses "Merchant";
update the linkLabel logic (inside linkLabel, where link.isExternal is checked)
to derive the label from the connected external node's type instead of
hardcoding "Merchant": check for a node/type field on the link (e.g.
link.externalNode?.type or link.connectedNode?.type, falling back to
link.nodeType if present) and map that value to a friendly label (e.g.
"Merchant", "Wallet", "Bank", etc.), while preserving the existing anonymized vs
full branches that use link.frequency/link.volume and link.txCount/link.totalUsd
as fallbacks.
- Around line 342-345: The effect that fetches external nodes needs to rerun
when the topNodes filter changes: add topNodes to the useEffect dependency array
that uses externalNodesFetchedLimitRef so the fetch uses the updated top-N
filter, and reset externalNodesFetchedLimitRef.current = null when topNodes
changes (so the "last fetched limit" check won't block a refetch for the new
topNodes value); locate the effect that references externalNodesFetchedLimitRef
and topNodes and update its dependencies and reset logic accordingly.
- Around line 588-603: When filtering externalNodesData in the payment mode
branch (inside the externalNodesData.filter block where isPaymentMode is
checked), don't treat missing node.userIds as zero; instead fall back to
deriving unique user IDs from node.userTxData by using the existing
extractUserIdFromKey helper (the same approach used elsewhere) to parse user IDs
from keys, deduplicate them, and set userCount to that unique count; keep the
non-payment branch using getExternalNodeUsers and continue comparing userCount
against externalNodesConfig.minConnections.

In `@src/components/Global/InvitesGraph/types.ts`:
- Around line 1-2: The file's import line formatting violates Prettier rules (CI
warning) for the symbols ExternalNode, ExternalNodeType, SizeLabel,
FrequencyLabel, and VolumeLabel; fix by running the formatter (pnpm prettier
--write) on this file or reformat the import statement to match project Prettier
rules (ensure commas, spacing, and trailing newline follow configured style) so
the import line is properly formatted and the CI check passes.

In `@src/constants/routes.ts`:
- Around line 54-67: The comments above PUBLIC_ROUTES and PUBLIC_ROUTES_REGEX
incorrectly state that /dev/payment-graph is public via API key; update those
explanatory notes to reflect the actual auth mechanism (password-gated) so they
match the implementation: edit the comment blocks referencing PUBLIC_ROUTES and
PUBLIC_ROUTES_REGEX to replace "uses API key instead of user auth" with "is
password-gated" (or equivalent), ensuring both occurrences mention the same auth
method for /dev/payment-graph to avoid confusion.

In `@src/hooks/useGraphPreferences.ts`:
- Around line 60-65: The clearPreferences function currently removes the main
storageKey from localStorage but leaves the `${storageKey}-expiry` entry behind;
update clearPreferences (in useGraphPreferences) to also remove the expiry key
by calling localStorage.removeItem(`${storageKey}-expiry`) whenever you remove
storageKey so both the preferences and their expiry are cleared (refer to
storageKey, clearPreferences, and saveToLocalStorage to match the expiry key
naming).
🧹 Nitpick comments (1)
src/services/points.ts (1)

118-149: Timeout handling is ineffective with fetchWithSentry’s internal AbortController.
fetchWithSentry replaces the passed signal, so the local 30s timeout won’t actually cancel the request. Consider relying on fetchWithSentry’s timeoutMs argument instead to avoid a false timeout expectation.

♻️ Suggested cleanup
-        const controller = new AbortController()
-        const timeoutId = setTimeout(() => controller.abort(), 30000)
-
-        const response = await fetchWithSentry(`${PEANUT_API_URL}${endpoint}`, {
-            method: 'GET',
-            headers,
-            signal: controller.signal,
-        })
-
-        clearTimeout(timeoutId)
+        const response = await fetchWithSentry(
+            `${PEANUT_API_URL}${endpoint}`,
+            {
+                method: 'GET',
+                headers,
+            },
+            30000
+        )

Comment on lines +1 to +23
'use client'

import { usePathname } from 'next/navigation'
import { notFound } from 'next/navigation'
import { IS_DEV } from '@/constants/general.consts'

// Routes that are allowed in production (protected by API key / user check)
const PRODUCTION_ALLOWED_ROUTES = ['/dev/full-graph', '/dev/payment-graph']

export default function DevLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()

// In production, only allow specific routes (full-graph, payment-graph)
// Other dev tools (leaderboard, shake-test, dev index) are dev-only
if (!IS_DEV) {
const isAllowedInProd = PRODUCTION_ALLOWED_ROUTES.some((route) => pathname?.startsWith(route))
if (!isAllowedInProd) {
notFound()
}
}

return <>{children}</>
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Next.js notFound() function server-only client component behavior

💡 Result:

What notFound() does (App Router)

  • notFound() is control flow, not a render helper: calling it throws a special internal error (NEXT_HTTP_ERROR_FALLBACK;404) that immediately stops rendering for the current route segment and causes Next.js to render that segment’s not-found.(js|tsx) UI (and injects noindex). [1]
  • not-found.(js|tsx) is the file that renders when notFound() is thrown in that segment (and the root app/not-found also handles unmatched URLs). By default it’s a Server Component; you can make it a Client Component if you need client hooks, but then you must do client-side data work yourself. [2]

Where you can (and can’t) use it

  • Supported locations: Server Components, Route Handlers, and Server Actions. [3]
  • Not supported in Client Components ('use client'): calling notFound() there is not a supported way to show a 404 boundary.

Practical patterns when you “need 404” from client code

  1. Move the existence check to a Server Component (page/layout) and call notFound() there. [1]
  2. If the decision only happens after a client interaction, call a Server Action that can notFound() (or return a flag), then refresh/navigate accordingly. [3]
  3. If you just want a client-side UX, navigate (e.g., router.replace('/somewhere')) or render your own “not found” UI—don’t rely on notFound() from the client.

Sources: Next.js notFound() docs [1], Next.js not-found file docs [2], Next.js source comment describing supported environments [3].


🏁 Script executed:

# Search for how notFound() is used elsewhere in the codebase
rg 'notFound' --type typescript --type tsx -B 2 -A 2

Repository: peanutprotocol/peanut-ui

Length of output: 102


🏁 Script executed:

# Search for notFound usage with correct syntax
rg 'notFound' -A 3 -B 3

Repository: peanutprotocol/peanut-ui

Length of output: 4655


🏁 Script executed:

# Check if notFound can be imported in 'use client' files - search for such patterns
rg "'use client'" -A 10 | rg -A 5 'notFound'

Repository: peanutprotocol/peanut-ui

Length of output: 527


🏁 Script executed:

# Check the actual file to see what would happen
cat -n src/app/\(mobile-ui\)/dev/layout.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 1028


🏁 Script executed:

# Check if there are other client components with access guards or similar patterns
rg "'use client'" -A 15 | rg -B 2 -A 2 'useRouter\|router\.replace\|router\.push'

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

# Check what IS_DEV is and when notFound would be called
rg 'IS_DEV' src/constants/general.consts -A 2 -B 2

Repository: peanutprotocol/peanut-ui

Length of output: 136


🏁 Script executed:

# Find where IS_DEV is defined
fd -e ts -e tsx -e js | xargs rg 'export.*IS_DEV' | head -20

Repository: peanutprotocol/peanut-ui

Length of output: 365


🏁 Script executed:

# Check if there are server components used as wrappers in the dev route
find src/app/\(mobile-ui\)/dev -type f -name '*.tsx' -o -name '*.ts'

Repository: peanutprotocol/peanut-ui

Length of output: 315


🏁 Script executed:

# Check if there are middleware or other guards in place for dev routes
rg 'middleware' src/ -l

Repository: peanutprotocol/peanut-ui

Length of output: 123


🏁 Script executed:

# Check parent layouts to see if there's already a dev guard
fd -path '*/dev*' -name 'layout.tsx' -o -path '*/dev*' -name 'page.tsx' | xargs grep -l "'use client'" 2>/dev/null

Repository: peanutprotocol/peanut-ui

Length of output: 238


🏁 Script executed:

# Check if there are Server Components wrapping the dev route
cat src/app/\(mobile-ui\)/page.tsx | head -50

Repository: peanutprotocol/peanut-ui

Length of output: 130


🏁 Script executed:

# Check if dev layout could be a Server Component instead
grep -r "export default.*function\|export default async function" src/app/\(mobile-ui\)/ | head -5

Repository: peanutprotocol/peanut-ui

Length of output: 590


🏁 Script executed:

# Verify what the actual error would be - search Next.js issues or similar patterns
rg "notFound.*use client" --type-list

Repository: peanutprotocol/peanut-ui

Length of output: 457


🏁 Script executed:

# Look at the structure - is there a parent layout that could handle this?
cat -n "src/app/(mobile-ui)/layout.tsx" 2>/dev/null | head -30 || echo "File not found"

Repository: peanutprotocol/peanut-ui

Length of output: 1713


🏁 Script executed:

# Check if '/404' is a valid fallback or if there's a custom 404 page
fd -e tsx -e ts -path '*/404*'

Repository: peanutprotocol/peanut-ui

Length of output: 236


🏁 Script executed:

# Verify that the proposed pattern (useEffect + router.replace) is used elsewhere
rg "useEffect.*router\.replace\|router\.replace.*useEffect" -B 3 -A 3

Repository: peanutprotocol/peanut-ui

Length of output: 50


Remove notFound() from this Client Component; use server-side guard instead.

notFound() is not supported in Client Components. It will not set a 404 status and may fail silently. Since the guard requires usePathname() (a client hook), the cleanest fix is to move the permission check to a parent Server Component or wrap this layout in a server-side guard.

Alternatively, if keeping it as a Client Component, use client-side redirect on mount:

Client-side redirect pattern
 'use client'

 import { usePathname, useRouter } from 'next/navigation'
-import { notFound } from 'next/navigation'
+import { useEffect } from 'react'
 import { IS_DEV } from '@/constants/general.consts'

 const PRODUCTION_ALLOWED_ROUTES = ['/dev/full-graph', '/dev/payment-graph']

 export default function DevLayout({ children }: { children: React.ReactNode }) {
     const pathname = usePathname()
+    const router = useRouter()
+
+    useEffect(() => {
+        if (!IS_DEV && pathname) {
+            const allowed = PRODUCTION_ALLOWED_ROUTES.some(
+                (route) => pathname === route || pathname.startsWith(`${route}/`)
+            )
+            if (!allowed) {
+                router.replace('/')
+            }
+        }
+    }, [IS_DEV, pathname, router])

     // In production, only allow specific routes (full-graph, payment-graph)
     // Other dev tools (leaderboard, shake-test, dev index) are dev-only
-    if (!IS_DEV) {
-        const isAllowedInProd = PRODUCTION_ALLOWED_ROUTES.some((route) => pathname?.startsWith(route))
-        if (!isAllowedInProd) {
-            notFound()
-        }
-    }

     return <>{children}</>
 }

Note: startsWith alone allows unintended prefixes (e.g., /dev/full-graphx); the fix above uses exact-or-subpath matching.

🤖 Prompt for AI Agents
In `@src/app/`(mobile-ui)/dev/layout.tsx around lines 1 - 23, DevLayout currently
calls notFound() from a Client Component (using usePathname), which is
unsupported; remove the notFound() call and move the production-route guard into
a parent Server Component (or create a server-side wrapper) that checks IS_DEV
and PRODUCTION_ALLOWED_ROUTES and returns a proper 404 there, or if you must
stay client-side implement a client-side redirect on mount instead of notFound;
also tighten the route check in PRODUCTION_ALLOWED_ROUTES logic (use exact match
or ensure the path segment boundary when checking startsWith to avoid matching
prefixes like "/dev/full-graphx"). Reference: DevLayout, usePathname, notFound,
IS_DEV, PRODUCTION_ALLOWED_ROUTES.

Comment on lines +342 to 345
// Track fetch state: stores the limit used for last fetch, or null if never fetched
// This allows refetch when limit changes while preventing refetch on toggle off/on
const externalNodesFetchedLimitRef = useRef<number | null>(null)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and get its size
wc -l src/components/Global/InvitesGraph/index.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 117


🏁 Script executed:

# Read the code around line 342-345 (ref declaration)
sed -n '335,350p' src/components/Global/InvitesGraph/index.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 950


🏁 Script executed:

# Read the code around lines 836-883 (the effect)
sed -n '830,890p' src/components/Global/InvitesGraph/index.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 3121


External nodes must refetch when topNodes changes.

The request includes topNodes to filter the external nodes, but the effect dependency array omits it. When topNodes changes, the effect doesn't re-run, causing external nodes to be fetched with an outdated top-N filter.

Keep external nodes in sync with topNodes
-    const externalNodesFetchedLimitRef = useRef<number | null>(null)
+    const externalNodesFetchedRef = useRef<{ limit: number; topNodes: number } | null>(null)

@@
-        const lastLimit = externalNodesFetchedLimitRef.current
-        if (lastLimit !== null && lastLimit >= externalNodesConfig.limit) return
+        const lastFetch = externalNodesFetchedRef.current
+        if (lastFetch && lastFetch.limit >= externalNodesConfig.limit && lastFetch.topNodes === topNodes) return

@@
-                    externalNodesFetchedLimitRef.current = externalNodesConfig.limit
+                    externalNodesFetchedRef.current = { limit: externalNodesConfig.limit, topNodes }

@@
-    }, [isMinimal, !isMinimal && props.apiKey, externalNodesConfig.enabled, mode, externalNodesConfig.limit])
+    }, [isMinimal, !isMinimal && props.apiKey, externalNodesConfig.enabled, mode, externalNodesConfig.limit, topNodes])
🤖 Prompt for AI Agents
In `@src/components/Global/InvitesGraph/index.tsx` around lines 342 - 345, The
effect that fetches external nodes needs to rerun when the topNodes filter
changes: add topNodes to the useEffect dependency array that uses
externalNodesFetchedLimitRef so the fetch uses the updated top-N filter, and
reset externalNodesFetchedLimitRef.current = null when topNodes changes (so the
"last fetched limit" check won't block a refetch for the new topNodes value);
locate the effect that references externalNodesFetchedLimitRef and topNodes and
update its dependencies and reset logic accordingly.

Comment on lines +588 to +603
const filtered = externalNodesData.filter((node) => {
// Filter by minConnections
if (node.uniqueUsers < externalNodesConfig.minConnections) return false
// In payment mode: count unique user IDs from userIds array
// In full mode: use uniqueUsers or fall back to size label conversion
let userCount: number
if (isPaymentMode) {
// Payment mode: count actual user IDs in the array
userCount = node.userIds?.length || 0
} else {
// Full mode: use helper which reads uniqueUsers or converts size label
userCount = getExternalNodeUsers(node)
}

if (userCount < externalNodesConfig.minConnections) {
return false
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/components/Global/InvitesGraph/index.tsx | sed -n '580,610p'

Repository: peanutprotocol/peanut-ui

Length of output: 1652


🏁 Script executed:

# Find the type definition for the node
rg -A 5 'type.*ExternalNode|interface.*ExternalNode' src/components/Global/InvitesGraph/

Repository: peanutprotocol/peanut-ui

Length of output: 1716


🏁 Script executed:

# Find the getExternalNodeUsers function
rg -B 2 -A 10 'function getExternalNodeUsers|const getExternalNodeUsers' src/components/Global/InvitesGraph/

Repository: peanutprotocol/peanut-ui

Length of output: 1144


🏁 Script executed:

# Find the ExternalNode type definition
rg -B 2 -A 15 'type ExternalNode' src/services/points.ts

Repository: peanutprotocol/peanut-ui

Length of output: 1631


🏁 Script executed:

# Search for userTxData usage in the InvitesGraph component
rg -n 'userTxData' src/components/Global/InvitesGraph/

Repository: peanutprotocol/peanut-ui

Length of output: 1373


🏁 Script executed:

# Find extractUserIdFromKey function
rg -B 2 -A 8 'extractUserIdFromKey|function extractUserIdFromKey' src/components/Global/InvitesGraph/index.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 1176


Payment-mode minConnections filtering drops all external nodes when userIds is missing.

In payment mode (line 594), userCount is set to 0 if userIds is absent, causing the filter to remove all nodes. Since userTxData is a required field in ExternalNode, a fallback should extract unique user IDs from it. To maintain consistency with the codebase's established pattern, use the existing extractUserIdFromKey function (defined earlier in the file) rather than duplicating the parsing logic.

✅ Use existing extractUserIdFromKey function for payment mode fallback
             if (isPaymentMode) {
-                // Payment mode: count actual user IDs in the array
-                userCount = node.userIds?.length || 0
+                // Payment mode: prefer userIds, otherwise extract from userTxData
+                if (node.userIds?.length) {
+                    userCount = node.userIds.length
+                } else {
+                    const uniqueIds = new Set(
+                        Object.keys(node.userTxData || {}).map(extractUserIdFromKey)
+                    )
+                    userCount = uniqueIds.size
+                }
             } else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const filtered = externalNodesData.filter((node) => {
// Filter by minConnections
if (node.uniqueUsers < externalNodesConfig.minConnections) return false
// In payment mode: count unique user IDs from userIds array
// In full mode: use uniqueUsers or fall back to size label conversion
let userCount: number
if (isPaymentMode) {
// Payment mode: count actual user IDs in the array
userCount = node.userIds?.length || 0
} else {
// Full mode: use helper which reads uniqueUsers or converts size label
userCount = getExternalNodeUsers(node)
}
if (userCount < externalNodesConfig.minConnections) {
return false
}
const filtered = externalNodesData.filter((node) => {
// Filter by minConnections
// In payment mode: count unique user IDs from userIds array
// In full mode: use uniqueUsers or fall back to size label conversion
let userCount: number
if (isPaymentMode) {
// Payment mode: prefer userIds, otherwise extract from userTxData
if (node.userIds?.length) {
userCount = node.userIds.length
} else {
const uniqueIds = new Set(
Object.keys(node.userTxData || {}).map(extractUserIdFromKey)
)
userCount = uniqueIds.size
}
} else {
// Full mode: use helper which reads uniqueUsers or converts size label
userCount = getExternalNodeUsers(node)
}
if (userCount < externalNodesConfig.minConnections) {
return false
}
🤖 Prompt for AI Agents
In `@src/components/Global/InvitesGraph/index.tsx` around lines 588 - 603, When
filtering externalNodesData in the payment mode branch (inside the
externalNodesData.filter block where isPaymentMode is checked), don't treat
missing node.userIds as zero; instead fall back to deriving unique user IDs from
node.userTxData by using the existing extractUserIdFromKey helper (the same
approach used elsewhere) to parse user IDs from keys, deduplicate them, and set
userCount to that unique count; keep the non-payment branch using
getExternalNodeUsers and continue comparing userCount against
externalNodesConfig.minConnections.

Comment on lines +2317 to +2331
linkLabel={(link: any) => {
if (link.isP2P) {
// Handle both full (count/totalUsd) and anonymized (frequency/volume) modes
if (link.frequency && link.volume) {
return `P2P: ${link.frequency} activity, ${link.volume} volume`
}
return `P2P: ${link.count} txs ($${link.totalUsd?.toFixed(2) ?? '0'})`
}
if (link.isExternal) {
// Handle both full and anonymized modes
if (link.frequency && link.volume) {
return `Merchant: ${link.frequency} activity, ${link.volume} volume`
}
return `External: ${link.txCount} txs ($${link.totalUsd?.toFixed(2) ?? '0'})`
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

External link labels show “Merchant” for all types.
If wallet/bank types are enabled, the tooltip becomes misleading. Consider deriving the label from the connected external node’s type.

🏷️ Type‑aware label
         if (link.isExternal) {
+            const externalType = link.target?.externalType || link.source?.externalType
+            const label =
+                externalType === 'WALLET'
+                    ? 'Wallet'
+                    : externalType === 'BANK'
+                      ? 'Bank'
+                      : externalType === 'MERCHANT'
+                        ? 'Merchant'
+                        : 'External'
             // Handle both full and anonymized modes
             if (link.frequency && link.volume) {
-                return `Merchant: ${link.frequency} activity, ${link.volume} volume`
+                return `${label}: ${link.frequency} activity, ${link.volume} volume`
             }
-            return `External: ${link.txCount} txs ($${link.totalUsd?.toFixed(2) ?? '0'})`
+            return `${label}: ${link.txCount} txs ($${link.totalUsd?.toFixed(2) ?? '0'})`
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
linkLabel={(link: any) => {
if (link.isP2P) {
// Handle both full (count/totalUsd) and anonymized (frequency/volume) modes
if (link.frequency && link.volume) {
return `P2P: ${link.frequency} activity, ${link.volume} volume`
}
return `P2P: ${link.count} txs ($${link.totalUsd?.toFixed(2) ?? '0'})`
}
if (link.isExternal) {
// Handle both full and anonymized modes
if (link.frequency && link.volume) {
return `Merchant: ${link.frequency} activity, ${link.volume} volume`
}
return `External: ${link.txCount} txs ($${link.totalUsd?.toFixed(2) ?? '0'})`
}
linkLabel={(link: any) => {
if (link.isP2P) {
// Handle both full (count/totalUsd) and anonymized (frequency/volume) modes
if (link.frequency && link.volume) {
return `P2P: ${link.frequency} activity, ${link.volume} volume`
}
return `P2P: ${link.count} txs ($${link.totalUsd?.toFixed(2) ?? '0'})`
}
if (link.isExternal) {
const externalType = link.target?.externalType || link.source?.externalType
const label =
externalType === 'WALLET'
? 'Wallet'
: externalType === 'BANK'
? 'Bank'
: externalType === 'MERCHANT'
? 'Merchant'
: 'External'
// Handle both full and anonymized modes
if (link.frequency && link.volume) {
return `${label}: ${link.frequency} activity, ${link.volume} volume`
}
return `${label}: ${link.txCount} txs ($${link.totalUsd?.toFixed(2) ?? '0'})`
}
🤖 Prompt for AI Agents
In `@src/components/Global/InvitesGraph/index.tsx` around lines 2317 - 2331, The
external label currently always uses "Merchant"; update the linkLabel logic
(inside linkLabel, where link.isExternal is checked) to derive the label from
the connected external node's type instead of hardcoding "Merchant": check for a
node/type field on the link (e.g. link.externalNode?.type or
link.connectedNode?.type, falling back to link.nodeType if present) and map that
value to a friendly label (e.g. "Merchant", "Wallet", "Bank", etc.), while
preserving the existing anonymized vs full branches that use
link.frequency/link.volume and link.txCount/link.totalUsd as fallbacks.

Comment on lines +54 to 67
* Note: Most 'dev' routes require authentication and specific user authorization
* Exception: /dev/payment-graph is public (uses API key instead of user auth)
*/
export const PUBLIC_ROUTES = ['request/pay', 'claim', 'pay', 'support', 'invite', 'qr'] as const
export const PUBLIC_ROUTES = ['request/pay', 'claim', 'pay', 'support', 'invite', 'qr', 'dev/payment-graph'] as const

/**
* Regex pattern for public routes (used in layout.tsx)
* Matches paths that don't require authentication
*
* Note: Dev tools routes are NOT public - they require both authentication and specific user authorization
* Note: Most dev tools routes are NOT public - they require both authentication and specific user authorization
* Exception: /dev/payment-graph is public (uses API key instead of user auth)
*/
export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/.+|support|invite|qr)/
export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/.+|support|invite|qr|dev\/payment-graph)/

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update comment: payment graph is password-gated, not API-key.

The explanatory notes should match the actual auth mechanism to prevent confusion during maintenance/debugging.

✏️ Suggested comment fix
- * Exception: /dev/payment-graph is public (uses API key instead of user auth)
+ * Exception: /dev/payment-graph is public (password-gated instead of user auth)
@@
- * Exception: /dev/payment-graph is public (uses API key instead of user auth)
+ * Exception: /dev/payment-graph is public (password-gated instead of user auth)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* Note: Most 'dev' routes require authentication and specific user authorization
* Exception: /dev/payment-graph is public (uses API key instead of user auth)
*/
export const PUBLIC_ROUTES = ['request/pay', 'claim', 'pay', 'support', 'invite', 'qr'] as const
export const PUBLIC_ROUTES = ['request/pay', 'claim', 'pay', 'support', 'invite', 'qr', 'dev/payment-graph'] as const
/**
* Regex pattern for public routes (used in layout.tsx)
* Matches paths that don't require authentication
*
* Note: Dev tools routes are NOT public - they require both authentication and specific user authorization
* Note: Most dev tools routes are NOT public - they require both authentication and specific user authorization
* Exception: /dev/payment-graph is public (uses API key instead of user auth)
*/
export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/.+|support|invite|qr)/
export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/.+|support|invite|qr|dev\/payment-graph)/
* Note: Most 'dev' routes require authentication and specific user authorization
* Exception: /dev/payment-graph is public (password-gated instead of user auth)
*/
export const PUBLIC_ROUTES = ['request/pay', 'claim', 'pay', 'support', 'invite', 'qr', 'dev/payment-graph'] as const
/**
* Regex pattern for public routes (used in layout.tsx)
* Matches paths that don't require authentication
*
* Note: Most dev tools routes are NOT public - they require both authentication and specific user authorization
* Exception: /dev/payment-graph is public (password-gated instead of user auth)
*/
export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/.+|support|invite|qr|dev\/payment-graph)/
🤖 Prompt for AI Agents
In `@src/constants/routes.ts` around lines 54 - 67, The comments above
PUBLIC_ROUTES and PUBLIC_ROUTES_REGEX incorrectly state that /dev/payment-graph
is public via API key; update those explanatory notes to reflect the actual auth
mechanism (password-gated) so they match the implementation: edit the comment
blocks referencing PUBLIC_ROUTES and PUBLIC_ROUTES_REGEX to replace "uses API
key instead of user auth" with "is password-gated" (or equivalent), ensuring
both occurrences mention the same auth method for /dev/payment-graph to avoid
confusion.

Comment on lines 60 to 65
const clearPreferences = useCallback(() => {
setPreferences(null)
initialPrefsRef.current = null
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(GRAPH_PREFS_KEY)
localStorage.removeItem(storageKey)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Also clear the expiry key when removing preferences.
saveToLocalStorage writes a ${key}-expiry entry; leaving it behind can cause confusing “expired” reads later.

🧹 Proposed fix
         if (typeof localStorage !== 'undefined') {
             localStorage.removeItem(storageKey)
+            localStorage.removeItem(`${storageKey}-expiry`)
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const clearPreferences = useCallback(() => {
setPreferences(null)
initialPrefsRef.current = null
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(GRAPH_PREFS_KEY)
localStorage.removeItem(storageKey)
}
const clearPreferences = useCallback(() => {
setPreferences(null)
initialPrefsRef.current = null
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(storageKey)
localStorage.removeItem(`${storageKey}-expiry`)
}
🤖 Prompt for AI Agents
In `@src/hooks/useGraphPreferences.ts` around lines 60 - 65, The clearPreferences
function currently removes the main storageKey from localStorage but leaves the
`${storageKey}-expiry` entry behind; update clearPreferences (in
useGraphPreferences) to also remove the expiry key by calling
localStorage.removeItem(`${storageKey}-expiry`) whenever you remove storageKey
so both the preferences and their expiry are cleared (refer to storageKey,
clearPreferences, and saveToLocalStorage to match the expiry key naming).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants