Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions src/app/(mobile-ui)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import WalletNavigation from '@/components/Global/WalletNavigation'
import OfflineScreen from '@/components/Global/OfflineScreen'
import { ThemeProvider } from '@/config'
import { useAuth } from '@/context/authContext'
import { hasValidJwtToken } from '@/utils/auth'
import classNames from 'classnames'
import { usePathname } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
Expand All @@ -32,7 +31,6 @@ const Layout = ({ children }: { children: React.ReactNode }) => {

const { isFetchingUser, user } = useAuth()
const [isReady, setIsReady] = useState(false)
const [hasToken, setHasToken] = useState(false)
const isUserLoggedIn = !!user?.user.userId || false
const isHome = pathName === '/home'
const isHistory = pathName === '/history'
Expand All @@ -48,9 +46,6 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
const scrollableContentRef = useRef<Element | null>(null)

useEffect(() => {
// check for JWT token
setHasToken(hasValidJwtToken())

setIsReady(true)
}, [])

Expand Down Expand Up @@ -80,9 +75,17 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
// enable pull-to-refresh for both ios and android
usePullToRefresh({ shouldPullToRefresh })

const isRedirecting = useRef(false)

useEffect(() => {
if (!isPublicPath && isReady && !isFetchingUser && !user) {
router.push('/setup')
if (!isPublicPath && isReady && !isFetchingUser && !user && !isRedirecting.current) {
isRedirecting.current = true
router.replace('/setup')
// hard navigation fallback in case soft navigation silently fails
const fallback = setTimeout(() => {
window.location.replace('/setup')
}, 3000)
return () => clearTimeout(fallback)
}
}, [user, isFetchingUser, isReady, isPublicPath, router])

Expand All @@ -106,8 +109,8 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
)
}
} else {
// for protected paths, wait for user auth and account setup check
if (!isReady || isFetchingUser || !hasToken || !user || needsRedirect || isCheckingAccount) {
// for protected paths, wait for auth to settle before rendering
if (!isReady || isFetchingUser || !user || isCheckingAccount || needsRedirect) {
return (
<div className="flex h-[100dvh] w-full flex-col items-center justify-center">
<PeanutLoading />
Expand Down
6 changes: 5 additions & 1 deletion src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,10 @@ export default function QRPayPage() {
)
} else if (errorMsg.toLowerCase().includes('expired') || errorMsg.toLowerCase().includes('stale')) {
setErrorMessage('Payment session expired. Please scan the QR code again.')
} else if (qrType === EQrType.PIX) {
setErrorMessage(
'This specific QR code or merchant is not supported. Please try again with a different QR code.'
)
} else {
setErrorMessage(
'Could not complete payment. Please scan the QR code again. If problem persists contact support'
Expand All @@ -726,7 +730,7 @@ export default function QRPayPage() {
} finally {
setLoadingState('Idle')
}
}, [paymentLock?.code, signTransferUserOp, qrCode, currencyAmount, setLoadingState])
}, [paymentLock?.code, signTransferUserOp, qrCode, currencyAmount, setLoadingState, qrType])

const payQR = useCallback(async () => {
if (paymentProcessor === 'SIMPLEFI') {
Expand Down
6 changes: 5 additions & 1 deletion src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,11 @@ export default function WithdrawBankPage() {
<PaymentInfoRow label={'Routing Number'} value={getBicAndRoutingNumber()} />
</>
)}
<ExchangeRate accountType={bankAccount.type} nonEuroCurrency={nonEuroCurrency} />
<ExchangeRate
accountType={bankAccount.type}
nonEuroCurrency={nonEuroCurrency}
amountToConvert={amountToWithdraw}
/>
<PaymentInfoRow hideBottomBorder label="Fee" value={`$ 0.00`} />
</Card>

Expand Down
15 changes: 12 additions & 3 deletions src/app/api/peanut/user/get-user-from-cookie/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,20 @@ export async function GET(_request: NextRequest) {
})

if (response.status !== 200) {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}

// on auth failure, clear the jwt cookie and sw cache so the client
// can recover even if running old cached code
if (response.status === 401) {
headers['Set-Cookie'] = 'jwt-token=; Path=/; Max-Age=0; SameSite=Lax'
headers['Clear-Site-Data'] = '"cache"'
}

return new NextResponse('Error in get-from-cookie', {
status: response.status,
headers: {
'Content-Type': 'application/json',
},
headers,
Comment on lines +24 to +37
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

🧩 Analysis chain

🏁 Script executed:

rg -n "jwt-token" --type ts --type tsx --type js --type jsx -B 2 -A 2

Repository: peanutprotocol/peanut-ui

Length of output: 95


🏁 Script executed:

rg -n "Set-Cookie" --type ts --type tsx --type js --type jsx -B 2 -A 2

Repository: peanutprotocol/peanut-ui

Length of output: 95


🏁 Script executed:

fd -e "ts" -e "tsx" -e "js" -e "jsx" | xargs grep -l "cookie" -i | head -20

Repository: peanutprotocol/peanut-ui

Length of output: 849


🏁 Script executed:

rg "jwt-token" -n -B 2 -A 2

Repository: peanutprotocol/peanut-ui

Length of output: 20501


🏁 Script executed:

cat -n src/app/api/peanut/user/get-jwt-token/route.ts

Repository: peanutprotocol/peanut-ui

Length of output: 2448


🏁 Script executed:

cat -n src/app/api/peanut/user/logout-user/route.ts

Repository: peanutprotocol/peanut-ui

Length of output: 1092


🏁 Script executed:

cat -n src/app/api/peanut/user/get-user-from-cookie/route.ts | head -40

Repository: peanutprotocol/peanut-ui

Length of output: 1761


Add Secure flag to cookie-clear Set-Cookie header in production.

The original jwt-token cookie is set with secure: process.env.NODE_ENV === 'production', but the clear operation at line 31 omits the Secure flag. Include Secure in the Set-Cookie header to maintain consistency:

headers['Set-Cookie'] = 'jwt-token=; Path=/; Max-Age=0; SameSite=Lax; Secure'

This ensures proper deletion in production environments and aligns with the original cookie configuration.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/peanut/user/get-user-from-cookie/route.ts` around lines 24 - 37,
The Set-Cookie used to clear the jwt-token in the 401 branch omits the Secure
flag, so deletion may fail in production; update the headers assignment inside
the response.status === 401 block (the headers object and the Set-Cookie string)
to include the Secure attribute only when process.env.NODE_ENV === 'production'
(i.e., append "; Secure" in production) so it matches how the original cookie
was set and ensures proper deletion; modify the logic around
headers['Set-Cookie'] in this route handler to conditionally include Secure
while keeping Path=/; Max-Age=0; SameSite=Lax.

})
}

Expand Down
2 changes: 1 addition & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export default function LandingPage() {
{
id: '3',
question: 'Could a thief drain my wallet if they stole my phone?',
answer: 'Not without your face or fingerprint. The passkey is sealed in the Secure Enclave of your phone and never exported. It’s secured by NIST‑recommended P‑256 Elliptic Curve cryptography. Defeating that would be tougher than guessing all 10¹⁰¹⁰ combinations of a 30‑character password made of emoji.\nThis means that neither Peanut or even regulators could freeze, us or you to hand over your account, because we can’t hand over what we don’t have. Your key never touches our servers; compliance requests only see cryptographic and encrypted signatures. Cracking those signatures would demand more energy than the Sun outputs in a full century.',
answer: 'Not without your face or fingerprint. The passkey is sealed in the Secure Enclave of your phone and never exported. It’s secured by NIST‑recommended P‑256 Elliptic Curve cryptography. Defeating that would be tougher than guessing all 10¹⁰¹⁰ combinations of a 30‑character password made of emoji.\n This means your account is yours alone. Neither Peanut nor anyone else can freeze or seize it — because we never hold your keys. Your key never touches our servers; compliance requests only see cryptographic and encrypted signatures. Cracking those signatures would demand more energy than the Sun outputs in a full century.',
},
{
id: '4',
Expand Down
54 changes: 45 additions & 9 deletions src/components/ExchangeRate/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@ import { AccountType } from '@/interfaces'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import useGetExchangeRate, { type IExchangeRate } from '@/hooks/useGetExchangeRate'
import { useExchangeRate } from '@/hooks/useExchangeRate'
import { SYMBOLS_BY_CURRENCY_CODE } from '@/hooks/useCurrency'

// constants for exchange rate messages, specific to ExchangeRate component
const APPROXIMATE_VALUE_MESSAGE =
"This is an approximate value. The actual amount received may vary based on your bank's exchange rate"
const LOCAL_CURRENCY_LABEL = 'Amount you will receive'

interface IExchangeRateProps extends Omit<IExchangeRate, 'enabled'> {
nonEuroCurrency?: string
sourceCurrency?: string
amountToConvert?: string
}

const ExchangeRate = ({ accountType, nonEuroCurrency, sourceCurrency = 'USD' }: IExchangeRateProps) => {
const ExchangeRate = ({
accountType,
nonEuroCurrency,
sourceCurrency = 'USD',
amountToConvert,
}: IExchangeRateProps) => {
const { exchangeRate, isFetchingRate } = useGetExchangeRate({ accountType, enabled: !nonEuroCurrency })
const { exchangeRate: nonEruoExchangeRate, isLoading } = useExchangeRate({
sourceCurrency,
Expand All @@ -26,27 +38,51 @@ const ExchangeRate = ({ accountType, nonEuroCurrency, sourceCurrency = 'USD' }:
let displayValue = '-'
let isLoadingRate = false
let moreInfoText = ''
let rate: number | null = null

if (nonEuroCurrency) {
displayValue = nonEruoExchangeRate
? `1 ${sourceCurrency} = ${parseFloat(nonEruoExchangeRate.toString()).toFixed(4)} ${nonEuroCurrency}`
: '-'
isLoadingRate = isLoading
moreInfoText =
"This is an approximate value. The actual amount received may vary based on your bank's exchange rate"
rate = nonEruoExchangeRate
moreInfoText = APPROXIMATE_VALUE_MESSAGE
} else {
displayValue = exchangeRate ? `1 USD = ${parseFloat(exchangeRate).toFixed(4)} ${toCurrency}` : '-'
isLoadingRate = isFetchingRate
rate = exchangeRate ? parseFloat(exchangeRate) : null
moreInfoText = `Exchange rates apply when converting to ${toCurrency}`
}

// calculate local currency amount if provided
let localCurrencyAmount: string | null = null
if (amountToConvert && rate && rate > 0) {
const amount = parseFloat(amountToConvert)
if (!isNaN(amount) && amount > 0) {
localCurrencyAmount = (amount * rate).toFixed(2)
}
Comment on lines +57 to +63
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

Guard against comma‑formatted inputs when parsing amountToConvert.

parseFloat("1,234.56") becomes 1, which will understate the local amount. Consider stripping commas and using Number.isFinite.

🛠️ Suggested guard
-        const amount = parseFloat(amountToConvert)
-        if (!isNaN(amount) && amount > 0) {
+        const normalized = amountToConvert.replace(/,/g, '')
+        const amount = Number(normalized)
+        if (Number.isFinite(amount) && amount > 0) {
             localCurrencyAmount = (amount * rate).toFixed(2)
         }
📝 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
// calculate local currency amount if provided
let localCurrencyAmount: string | null = null
if (amountToConvert && rate && rate > 0) {
const amount = parseFloat(amountToConvert)
if (!isNaN(amount) && amount > 0) {
localCurrencyAmount = (amount * rate).toFixed(2)
}
// calculate local currency amount if provided
let localCurrencyAmount: string | null = null
if (amountToConvert && rate && rate > 0) {
const normalized = amountToConvert.replace(/,/g, '')
const amount = Number(normalized)
if (Number.isFinite(amount) && amount > 0) {
localCurrencyAmount = (amount * rate).toFixed(2)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ExchangeRate/index.tsx` around lines 57 - 63, The parsing of
amountToConvert using parseFloat can mis-handle comma-formatted values (e.g.,
"1,234.56"); update the logic around localCurrencyAmount/amountToConvert to
first sanitize the string by removing thousands separators (commas) and trim
whitespace, then parse to a number (e.g., Number(...) or parseFloat on the
cleaned string) and validate with Number.isFinite(amount) and amount > 0 before
calculating (amount * rate). Ensure the guard still checks rate && rate > 0 and
preserve the toFixed(2) assignment to localCurrencyAmount.

}

const currency = nonEuroCurrency || toCurrency
const currencySymbol = SYMBOLS_BY_CURRENCY_CODE[currency] || currency

return (
<PaymentInfoRow
loading={isLoadingRate}
label="Exchange Rate"
moreInfoText={moreInfoText}
value={displayValue}
/>
<>
<PaymentInfoRow
loading={isLoadingRate}
label="Exchange Rate"
moreInfoText={moreInfoText}
value={displayValue}
/>
{localCurrencyAmount && (
<PaymentInfoRow
loading={isLoadingRate}
label={LOCAL_CURRENCY_LABEL}
value={`~ ${currencySymbol}${localCurrencyAmount}`}
moreInfoText={APPROXIMATE_VALUE_MESSAGE}
/>
)}
</>
)
}

Expand Down
25 changes: 24 additions & 1 deletion src/components/Global/FAQs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@ export function FAQsPanel({ heading, questions }: FAQsProps) {
setOpenFaq((prevId) => (prevId === id ? null : id))
}, [])

// helper to convert urls in text to clickable links
const linkifyText = useCallback((text: string) => {
const urlRegex = /(https?:\/\/[^\s]+)/g
const parts = text.split(urlRegex)

return parts.map((part, index) => {
if (part.match(urlRegex)) {
return (
<a
key={index}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-black underline hover:text-accent"
>
{part}
</a>
)
}
return part
})
}, [])

return (
<div className="w-full overflow-x-hidden bg-background">
<div className="relative px-6 py-20 md:px-8 md:py-36">
Expand Down Expand Up @@ -80,7 +103,7 @@ export function FAQsPanel({ heading, questions }: FAQsProps) {
transition={{ duration: 0.2 }}
className="mt-1 overflow-hidden whitespace-pre-line leading-6 text-n-1"
>
{faq.answer}
{linkifyText(faq.answer)}
{faq.calModal && (
<a
data-cal-link="kkonrad+hugo0/15min?duration=30"
Expand Down
37 changes: 5 additions & 32 deletions src/components/Payment/PaymentInfoRow.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useId, useState } from 'react'
import { twMerge } from 'tailwind-merge'
import { Icon } from '../Global/Icons/Icon'
import Loading from '../Global/Loading'
import CopyToClipboard from '../Global/CopyToClipboard'
import { Tooltip } from '../Tooltip'

export interface PaymentInfoRowProps {
label: string | React.ReactNode
Expand All @@ -25,9 +25,6 @@ export const PaymentInfoRow = ({
copyValue,
onClick,
}: PaymentInfoRowProps) => {
const [showMoreInfo, setShowMoreInfo] = useState(false)
const tooltipId = useId()

return (
<div
className={twMerge(
Expand All @@ -41,34 +38,10 @@ export const PaymentInfoRow = ({
<div className="relative flex items-center">
<label className={twMerge('text-xs font-semibold')}>{label}</label>
{moreInfoText && (
<div
className="relative z-20 flex items-center justify-center px-2"
role="button"
tabIndex={0}
aria-describedby={tooltipId}
onFocus={() => setShowMoreInfo(true)}
onBlur={() => setShowMoreInfo(false)}
>
<Icon
name="info"
size={12}
className="cursor-pointer"
onClick={() => setShowMoreInfo(!showMoreInfo)}
/>
{showMoreInfo && (
<div
className="absolute left-5 top-1/2 z-30 ml-2 w-max max-w-[210px] -translate-y-1/2 transform rounded-sm border border-black bg-white p-3 text-xs font-normal shadow-sm md:max-w-xs"
id={tooltipId}
role="tooltip"
aria-hidden={!showMoreInfo}
>
<div className="relative">
<div className="absolute -left-5 top-1/2 h-0 w-0 -translate-y-1/2 transform border-b-[9px] border-r-[8px] border-t-[9px] border-b-transparent border-r-black border-t-transparent"></div>
<div className="absolute -left-[19px] top-1/2 z-20 h-0 w-0 -translate-y-1/2 transform border-b-[9px] border-r-[8px] border-t-[9px] border-b-transparent border-r-white border-t-transparent"></div>
{moreInfoText}
</div>
</div>
)}
<div className="relative z-20 flex items-center justify-center px-2">
<Tooltip content={moreInfoText} position="right">
<Icon name="info" size={12} />
</Tooltip>
</div>
)}
</div>
Expand Down
37 changes: 10 additions & 27 deletions src/hooks/query/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useAppDispatch, useUserStore } from '@/redux/hooks'
import { userActions } from '@/redux/slices/user-slice'
import { fetchWithSentry } from '@/utils/sentry.utils'
import { hitUserMetric } from '@/utils/metrics.utils'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { usePWAStatus } from '../usePWAStatus'
import { useDeviceType } from '../useGetDeviceType'
import { USER } from '@/constants/query.consts'
Expand All @@ -29,14 +29,9 @@ export const useUserQuery = (dependsOn: boolean = true) => {

return userData
} else {
// RECOVERY FIX: Log error status for debugging
if (userResponse.status === 400 || userResponse.status === 500) {
console.error('Failed to fetch user with error status:', userResponse.status)
// This indicates a backend issue - user might be in broken state
// The KernelClientProvider recovery logic will handle cleanup
} else {
console.warn('Failed to fetch user. Probably not logged in.')
}
console.warn('Failed to fetch user, status:', userResponse.status)
// clear stale redux data so the app doesn't keep serving cached user
dispatch(userActions.setUser(null))
return null
}
Comment on lines 31 to 36
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

Clearing user data on any fetch failure may cause unintended logouts.

The current implementation clears Redux user data for all non-OK responses, including temporary network issues (500, timeouts, connection errors). This could log users out during transient server issues or brief connectivity problems.

Consider differentiating between authentication failures (401/403) and server/network errors:

🛡️ Proposed fix to preserve user data on transient errors
         } else {
-            console.warn('Failed to fetch user, status:', userResponse.status)
-            // clear stale redux data so the app doesn't keep serving cached user
-            dispatch(userActions.setUser(null))
+            console.warn('Failed to fetch user, status:', userResponse.status)
+            // only clear user data on auth failures, not transient server errors
+            if (userResponse.status === 401 || userResponse.status === 403) {
+                dispatch(userActions.setUser(null))
+            }
             return null
         }
📝 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
} else {
// RECOVERY FIX: Log error status for debugging
if (userResponse.status === 400 || userResponse.status === 500) {
console.error('Failed to fetch user with error status:', userResponse.status)
// This indicates a backend issue - user might be in broken state
// The KernelClientProvider recovery logic will handle cleanup
} else {
console.warn('Failed to fetch user. Probably not logged in.')
}
console.warn('Failed to fetch user, status:', userResponse.status)
// clear stale redux data so the app doesn't keep serving cached user
dispatch(userActions.setUser(null))
return null
}
} else {
console.warn('Failed to fetch user, status:', userResponse.status)
// only clear user data on auth failures, not transient server errors
if (userResponse.status === 401 || userResponse.status === 403) {
dispatch(userActions.setUser(null))
}
return null
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/query/user.ts` around lines 31 - 36, The code currently clears
Redux user state for any non-OK fetch by calling
dispatch(userActions.setUser(null)) when userResponse.status !== 200; change
this so only authentication errors trigger logout: detect 401 and 403 from
userResponse.status and call dispatch(userActions.setUser(null)) only in that
branch, while for other statuses (5xx, 429, etc.) just log a warning and return
null without clearing state; also ensure any fetch/network exceptions caught by
the enclosing function (e.g., in the same async function that awaits the fetch)
do not call userActions.setUser(null) on transient errors — handle them by
logging and returning null instead.

}
Expand All @@ -45,25 +40,13 @@ export const useUserQuery = (dependsOn: boolean = true) => {
queryKey: [USER],
queryFn: fetchUser,
retry: 0,
// Enable if dependsOn is true (defaults to true) and no Redux user exists yet
enabled: dependsOn && !authUser?.user.userId,
// Two-tier caching strategy for optimal performance:
// TIER 1: TanStack Query in-memory cache (5 min)
// - Zero latency for active sessions
// - Lost on page refresh (intentional - forces SW cache check)
// TIER 2: Service Worker disk cache (1 week StaleWhileRevalidate)
// - <50ms response on cold start/offline
// - Persists across sessions
// Flow: TQ cache → if stale → fetch() → SW intercepts → SW cache → Network
staleTime: 5 * 60 * 1000, // 5 min (balance: fresh enough + reduces SW hits)
gcTime: 10 * 60 * 1000, // Keep unused data 10 min before garbage collection
// Refetch on mount - TQ automatically skips if data is fresh (< staleTime)
enabled: dependsOn,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnMount: true,
// Refetch on focus - TQ automatically skips if data is fresh (< staleTime)
refetchOnWindowFocus: true,
// Initialize with Redux data if available (hydration)
initialData: authUser || undefined,
// Keep previous data during refetch (smooth UX, no flicker)
placeholderData: keepPreviousData,
// use redux data as placeholder while fetching (no flicker)
// but always validate against the backend
placeholderData: authUser || undefined,
})
}
2 changes: 1 addition & 1 deletion src/redux/slices/user-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const userSlice = createSlice({
name: AUTH_SLICE,
initialState,
reducers: {
setUser: (state, action: PayloadAction<IUserProfile>) => {
setUser: (state, action: PayloadAction<IUserProfile | null>) => {
state.user = action.payload
},
},
Expand Down
Loading