diff --git a/redirects.json b/redirects.json
index 7d110d33c..edac41e56 100644
--- a/redirects.json
+++ b/redirects.json
@@ -21,7 +21,12 @@
},
{
"source": "/:path*",
- "has": [{ "type": "host", "value": "docs.peanut.me" }],
+ "has": [
+ {
+ "type": "host",
+ "value": "docs.peanut.me"
+ }
+ ],
"destination": "https://peanut.me/en/help",
"permanent": true
},
@@ -74,5 +79,15 @@
"source": "/seedlings",
"destination": "https://peanutprotocol.notion.site/Influencer-information-form-2af8381175798067b271cd95a04d922a",
"permanent": false
+ },
+ {
+ "source": "/presskit",
+ "destination": "https://peanutprotocol.notion.site/Press-Kit-12f83811757981fc9ca5de581b20f50d",
+ "permanent": true
+ },
+ {
+ "source": "/press-kit",
+ "destination": "https://peanutprotocol.notion.site/Press-Kit-12f83811757981fc9ca5de581b20f50d",
+ "permanent": true
}
]
diff --git a/scripts/validate-links.ts b/scripts/validate-links.ts
index 430868946..06edfceb1 100644
--- a/scripts/validate-links.ts
+++ b/scripts/validate-links.ts
@@ -42,12 +42,31 @@ function buildValidPaths(): Set {
paths.add(p)
}
+ // App routes (behind auth / mobile-ui) — content may link to these
+ for (const p of [
+ '/profile',
+ '/profile/backup',
+ '/profile/edit',
+ '/profile/exchange-rate',
+ '/profile/identity-verification',
+ '/home',
+ '/send',
+ '/request',
+ '/settings',
+ '/history',
+ '/points',
+ '/recover-funds',
+ ]) {
+ paths.add(p)
+ }
+
const countrySlugs = listDirs(path.join(CONTENT_DIR, 'countries'))
const competitorSlugs = listDirs(path.join(CONTENT_DIR, 'compare'))
const payWithSlugs = listDirs(path.join(CONTENT_DIR, 'pay-with'))
const depositSlugs = listDirs(path.join(CONTENT_DIR, 'deposit'))
const helpSlugs = listDirs(path.join(CONTENT_DIR, 'help'))
const useCaseSlugs = listDirs(path.join(CONTENT_DIR, 'use-cases'))
+ const storySlugs = listDirs(path.join(CONTENT_DIR, 'stories')).filter((s) => s !== 'index')
const withdrawSlugs = listDirs(path.join(CONTENT_DIR, 'withdraw'))
const exchangeSlugs = listEntitySlugs('exchanges')
@@ -117,6 +136,14 @@ function buildValidPaths(): Set {
paths.add(`/${locale}/use-cases/${slug}`)
}
+ // Stories: /{locale}/stories and /{locale}/stories/{slug}
+ if (storySlugs.length > 0) {
+ paths.add(`/${locale}/stories`)
+ for (const slug of storySlugs) {
+ paths.add(`/${locale}/stories/${slug}`)
+ }
+ }
+
// Withdraw: /{locale}/withdraw/{slug}
for (const slug of withdrawSlugs) {
paths.add(`/${locale}/withdraw/to-${slug}`)
diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx
index 4282c07ca..4be146fe2 100644
--- a/src/app/(mobile-ui)/qr-pay/page.tsx
+++ b/src/app/(mobile-ui)/qr-pay/page.tsx
@@ -700,7 +700,24 @@ export default function QRPayPage() {
clearTimeout(payingStateTimerRef.current)
payingStateTimerRef.current = null
}
+ // Map backend field name (sponsoredUsd) to frontend field name (amountSponsored)
+ const perkResponse = qrPayment.perk as Record | undefined
+ if (qrPayment.perk && typeof perkResponse?.sponsoredUsd === 'number') {
+ qrPayment.perk.amountSponsored = perkResponse.sponsoredUsd
+ }
+
setQrPayment(qrPayment)
+
+ // Auto-claim small perks (<$0.50) — skip the hold-to-claim ceremony.
+ // The backend already claimed the perk at payment time, so this is just a UI shortcut.
+ if (
+ qrPayment.perk?.eligible &&
+ typeof qrPayment.perk.amountSponsored === 'number' &&
+ qrPayment.perk.amountSponsored < 0.5
+ ) {
+ setPerkClaimed(true)
+ }
+
setIsSuccess(true)
} catch (error) {
// clear the timer on error to prevent race condition
@@ -1234,31 +1251,21 @@ export default function QRPayPage() {
Eligible for a Peanut Perk!
{(() => {
- const percentage = qrPayment?.perk?.discountPercentage || 100
const amountSponsored = qrPayment?.perk?.amountSponsored
const transactionUsd =
parseFloat(qrPayment?.details?.paymentAgainstAmount || '0') || 0
- // Check if percentage matches the actual math (within 1% tolerance)
- let percentageMatches = false
- if (amountSponsored && transactionUsd > 0) {
- const actualPercentage = (amountSponsored / transactionUsd) * 100
- percentageMatches = Math.abs(actualPercentage - percentage) < 1
- }
-
- if (percentageMatches) {
- if (percentage === 100) {
- return 'This bill can be covered by Peanut. Claim it now to unlock your reward.'
- } else if (percentage > 100) {
- return `You're getting ${percentage}% back — that's more than you paid! Claim it now.`
- } else {
- return `You're getting ${percentage}% cashback! Claim it now to unlock your reward.`
+ // Always show actual dollar amount — never percentage (misleading due to dynamic caps)
+ // Note: perks <$0.50 are auto-claimed and skip this banner entirely
+ if (amountSponsored && typeof amountSponsored === 'number') {
+ if (transactionUsd > 0 && amountSponsored >= transactionUsd) {
+ return `This bill can be covered by Peanut! $${amountSponsored.toFixed(2)} back. Claim it now.`
}
+ return `Peanut's got you! $${amountSponsored.toFixed(2)} back on this payment. Claim it now.`
}
- return amountSponsored && typeof amountSponsored === 'number'
- ? `Get $${amountSponsored.toFixed(2)} back!`
- : 'Claim it now to unlock your reward.'
+ // Fallback: no amount available yet
+ return 'You earned a Peanut Perk! Claim it now to unlock your reward.'
})()}
@@ -1278,17 +1285,22 @@ export default function QRPayPage() {
const amountSponsored = qrPayment?.perk?.amountSponsored
const transactionUsd =
parseFloat(qrPayment?.details?.paymentAgainstAmount || '0') || 0
- const percentage =
- amountSponsored && transactionUsd > 0
- ? Math.round((amountSponsored / transactionUsd) * 100)
- : qrPayment?.perk?.discountPercentage || 100
- if (percentage === 100) {
- return 'We paid for this bill! Earn points, climb tiers and unlock even better perks.'
- } else if (percentage > 100) {
- return `We gave you ${percentage}% back — that's more than you paid! Earn points, climb tiers and unlock even better perks.`
- } else {
- return `We gave you ${percentage}% cashback! Earn points, climb tiers and unlock even better perks.`
+
+ // Tone scales with amount: small = growth nudge, large = celebratory
+ if (amountSponsored && typeof amountSponsored === 'number') {
+ if (transactionUsd > 0 && amountSponsored >= transactionUsd) {
+ return 'We paid for this bill! Earn points, climb tiers and unlock even better perks.'
+ }
+ if (amountSponsored < 0.5) {
+ return `You got $${amountSponsored.toFixed(2)} back. The more friends you invite, the more you earn!`
+ }
+ if (amountSponsored >= 5) {
+ return `We gave you $${amountSponsored.toFixed(2)} back! Your points are paying off.`
+ }
+ return `We gave you $${amountSponsored.toFixed(2)} back! Invite friends to unlock bigger rewards.`
}
+
+ return 'We gave you cashback! Earn points, climb tiers and unlock even better perks.'
})()}
diff --git a/src/app/api/health/backend/route.ts b/src/app/api/health/backend/route.ts
index 10bf1e40d..b4d2cd9f0 100644
--- a/src/app/api/health/backend/route.ts
+++ b/src/app/api/health/backend/route.ts
@@ -1,14 +1,21 @@
import { NextResponse } from 'next/server'
-import { fetchWithSentry } from '@/utils/sentry.utils'
import { PEANUT_API_URL } from '@/constants/general.consts'
export const dynamic = 'force-dynamic'
export const revalidate = 0
export const fetchCache = 'force-no-store'
+const NO_CACHE_HEADERS = {
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
+ Pragma: 'no-cache',
+ Expires: '0',
+ 'Surrogate-Control': 'no-store',
+}
+
/**
- * Health check for Peanut API backend
- * Tests connectivity to the main peanut-api-ts backend service
+ * Health check for Peanut API backend.
+ * Uses the backend's dedicated /healthz endpoint (checks DB connectivity).
+ * Uses plain fetch to avoid health check errors polluting Sentry.
*/
export async function GET() {
const startTime = Date.now()
@@ -23,28 +30,26 @@ export async function GET() {
error: 'PEANUT_API_URL not configured',
responseTime: Date.now() - startTime,
},
- { status: 500 }
+ { status: 500, headers: NO_CACHE_HEADERS }
)
}
- // Test backend connectivity by fetching a specific user endpoint
const backendTestStart = Date.now()
- const backendResponse = await fetchWithSentry(`${PEANUT_API_URL}/users/username/hugo`, {
+ const backendResponse = await fetch(`${PEANUT_API_URL}/healthz`, {
method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
+ headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
- next: { revalidate: 0 },
+ signal: AbortSignal.timeout(8000),
})
const backendResponseTime = Date.now() - backendTestStart
- // Backend is healthy if we get any response (200, 404, etc.) - what matters is connectivity
- if (!backendResponse.ok && backendResponse.status >= 500) {
- throw new Error(`Backend API returned server error ${backendResponse.status}`)
+ if (!backendResponse.ok) {
+ const errorData = await backendResponse.json().catch(() => null)
+ throw new Error(errorData?.error || `Backend /healthz returned ${backendResponse.status}`)
}
+ const healthData = await backendResponse.json()
const totalResponseTime = Date.now() - startTime
return NextResponse.json(
@@ -54,29 +59,16 @@ export async function GET() {
timestamp: new Date().toISOString(),
responseTime: totalResponseTime,
details: {
- apiConnectivity: {
+ healthz: {
status: 'healthy',
responseTime: backendResponseTime,
httpStatus: backendResponse.status,
apiUrl: PEANUT_API_URL,
- testEndpoint: '/users/username/hugo',
- message: backendResponse.ok
- ? 'Backend responding normally'
- : backendResponse.status === 404
- ? 'Backend accessible (user not found as expected)'
- : 'Backend accessible',
+ dbConnected: healthData.dbConnected ?? true,
},
},
},
- {
- status: 200,
- headers: {
- 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
- Pragma: 'no-cache',
- Expires: '0',
- 'Surrogate-Control': 'no-store',
- },
- }
+ { status: 200, headers: NO_CACHE_HEADERS }
)
} catch (error) {
const totalResponseTime = Date.now() - startTime
@@ -89,15 +81,7 @@ export async function GET() {
error: error instanceof Error ? error.message : 'Unknown error',
responseTime: totalResponseTime,
},
- {
- status: 500,
- headers: {
- 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
- Pragma: 'no-cache',
- Expires: '0',
- 'Surrogate-Control': 'no-store',
- },
- }
+ { status: 500, headers: NO_CACHE_HEADERS }
)
}
}
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
index 89c696bd0..35de0e57d 100644
--- a/src/app/api/health/route.ts
+++ b/src/app/api/health/route.ts
@@ -1,5 +1,4 @@
import { NextResponse } from 'next/server'
-import { fetchWithSentry } from '@/utils/sentry.utils'
import { SELF_URL } from '@/constants/general.consts'
export const dynamic = 'force-dynamic'
@@ -7,13 +6,24 @@ export const revalidate = 0
export const fetchCache = 'force-no-store'
/**
- * Overall health check endpoint
- * Aggregates health status from all individual service health checks
- * This is the main endpoint that should be monitored by UptimeRobot
+ * Overall health check endpoint.
+ * Aggregates health status from all individual service health checks.
+ * Monitored by UptimeRobot → status.peanut.me
+ *
+ * Discord webhook has a 30-minute cooldown to avoid spam when UptimeRobot
+ * polls every few minutes during an ongoing incident.
+ *
+ * Uses plain fetch for sub-checks to avoid health check errors polluting Sentry.
*/
+// In-memory cooldown for Discord notifications.
+// Vercel serverless functions are ephemeral, so this resets on cold starts —
+// that's acceptable since cold starts are infrequent enough to not cause spam.
+let lastNotificationTime = 0
+const NOTIFICATION_COOLDOWN_MS = 30 * 60 * 1000 // 30 minutes
+
/**
- * Send Discord notification when system is unhealthy
+ * Send Discord notification when system is unhealthy (with cooldown).
*/
async function sendDiscordNotification(healthData: any) {
try {
@@ -23,12 +33,25 @@ async function sendDiscordNotification(healthData: any) {
return
}
- // Create a detailed message about what's failing
+ // Cooldown check — don't spam Discord
+ const now = Date.now()
+ if (now - lastNotificationTime < NOTIFICATION_COOLDOWN_MS) {
+ console.log(
+ `Discord notification skipped (cooldown). Last sent ${Math.round((now - lastNotificationTime) / 1000)}s ago.`
+ )
+ return
+ }
+ lastNotificationTime = now
+
const failedServices = Object.entries(healthData.services)
.filter(([_, service]: [string, any]) => service.status === 'unhealthy')
.map(([name, service]: [string, any]) => `• ${name}: ${service.error || 'unhealthy'}`)
- // Only mention role in production or peanut.me
+ const degradedServices = Object.entries(healthData.services)
+ .filter(([_, service]: [string, any]) => service.status === 'degraded')
+ .map(([name, service]: [string, any]) => `• ${name}: ${service.error || 'degraded'}`)
+
+ // Only @mention the role in production
const isProduction = process.env.NODE_ENV === 'production'
const isPeanutDomain =
(process.env.NEXT_PUBLIC_BASE_URL?.includes('peanut.me') &&
@@ -38,78 +61,77 @@ async function sendDiscordNotification(healthData: any) {
const roleMention = shouldMentionRole ? '<@&1187109195389083739> ' : ''
- const message = `${roleMention}🚨 **Peanut Health Alert** 🚨
+ let message = `${roleMention}🚨 **Peanut Health Alert** 🚨
System Status: **${healthData.status.toUpperCase()}**
Health Score: ${healthData.healthScore}%
Environment: ${healthData.systemInfo?.environment || 'unknown'}
**Failed Services:**
-${failedServices.length > 0 ? failedServices.join('\n') : 'No specific failures detected'}
+${failedServices.length > 0 ? failedServices.join('\n') : 'No specific failures detected'}`
+
+ if (degradedServices.length > 0) {
+ message += `\n\n**Degraded Services:**\n${degradedServices.join('\n')}`
+ }
-**Summary:**
+ message += `\n\n**Summary:**
• Healthy: ${healthData.summary.healthy}
• Degraded: ${healthData.summary.degraded}
• Unhealthy: ${healthData.summary.unhealthy}
Timestamp: ${healthData.timestamp}`
- await fetchWithSentry(webhookUrl, {
+ await fetch(webhookUrl, {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- content: message,
- }),
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content: message }),
})
console.log('Discord notification sent for unhealthy system status')
} catch (error) {
console.error('Failed to send Discord notification:', error)
- // Don't throw - we don't want notification failures to break the health check
}
}
+const NO_CACHE_HEADERS = {
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
+ Pragma: 'no-cache',
+ Expires: '0',
+ 'Surrogate-Control': 'no-store',
+}
+
export async function GET() {
const startTime = Date.now()
try {
const services = ['mobula', 'squid', 'zerodev', 'rpc', 'justaname', 'backend', 'manteca']
- const HEALTH_CHECK_TIMEOUT = 8000 // 8 seconds per service
+ const HEALTH_CHECK_TIMEOUT = 8000
const healthChecks = await Promise.allSettled(
services.map(async (service) => {
- const timeoutPromise = new Promise((_, reject) => {
- setTimeout(
- () => reject(new Error(`${service} health check timeout after ${HEALTH_CHECK_TIMEOUT}ms`)),
- HEALTH_CHECK_TIMEOUT
- )
- })
-
- // Race the fetch against the timeout
- const healthCheckPromise = (async () => {
- const response = await fetchWithSentry(`${SELF_URL}/api/health/${service}`, {
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT)
+
+ try {
+ // Use plain fetch — health check errors are expected, not Sentry-worthy
+ const response = await fetch(`${SELF_URL}/api/health/${service}`, {
method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
+ headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
- next: { revalidate: 0 },
+ signal: controller.signal,
})
- if (!response.ok) {
- throw new Error(`Health check failed with status ${response.status}`)
- }
+ clearTimeout(timeoutId)
const data = await response.json()
- return {
- service,
- ...data,
- }
- })()
- return Promise.race([healthCheckPromise, timeoutPromise])
+ // Pass through the sub-check's own status rather than only trusting HTTP status.
+ // Sub-checks now return degraded (HTTP 200) for non-critical partial failures.
+ return { service, ...data }
+ } catch (error) {
+ clearTimeout(timeoutId)
+ throw error
+ }
})
)
@@ -128,31 +150,37 @@ export async function GET() {
const serviceName = services[index]
if (result.status === 'fulfilled') {
- const serviceData = result.value
+ const serviceData = result.value as any
+ const serviceStatus = serviceData.status || 'unhealthy'
+
results.services[serviceName] = {
- status: serviceData.status,
+ status: serviceStatus,
responseTime: serviceData.responseTime,
timestamp: serviceData.timestamp,
details: serviceData.details || {},
+ ...(serviceData.error ? { error: serviceData.error } : {}),
}
- // Update summary counts
- switch (serviceData.status) {
+ switch (serviceStatus) {
case 'healthy':
results.summary.healthy++
break
case 'degraded':
results.summary.degraded++
break
- case 'unhealthy':
default:
results.summary.unhealthy++
break
}
} else {
+ const errorMessage =
+ result.reason?.name === 'AbortError'
+ ? `${serviceName} health check timeout after ${HEALTH_CHECK_TIMEOUT}ms`
+ : result.reason?.message || 'Health check failed'
+
results.services[serviceName] = {
status: 'unhealthy',
- error: result.reason?.message || 'Health check failed',
+ error: errorMessage,
timestamp: new Date().toISOString(),
}
results.summary.unhealthy++
@@ -162,7 +190,6 @@ export async function GET() {
// Determine overall system health
let overallStatus = 'healthy'
if (results.summary.unhealthy > 0) {
- // If any critical services are down, mark as unhealthy
const criticalServices = ['backend', 'rpc']
const criticalServicesDown = criticalServices.some(
(service) => results.services[service]?.status === 'unhealthy'
@@ -179,67 +206,31 @@ export async function GET() {
const totalResponseTime = Date.now() - startTime
- // Calculate health score (0-100)
const healthScore = Math.round(
((results.summary.healthy + results.summary.degraded * 0.5) / results.summary.total) * 100
)
- // If overall status is unhealthy, return HTTP 500
- if (overallStatus === 'unhealthy') {
- const responseData = {
- status: overallStatus,
- service: 'peanut-protocol',
- timestamp: new Date().toISOString(),
- responseTime: totalResponseTime,
- healthScore,
- summary: results.summary,
- services: results.services,
- systemInfo: {
- environment: process.env.NODE_ENV,
- version: process.env.npm_package_version || 'unknown',
- region: process.env.VERCEL_REGION || 'unknown',
- },
- }
+ const responseData = {
+ status: overallStatus,
+ service: 'peanut-protocol',
+ timestamp: new Date().toISOString(),
+ responseTime: totalResponseTime,
+ healthScore,
+ summary: results.summary,
+ services: results.services,
+ systemInfo: {
+ environment: process.env.NODE_ENV,
+ version: process.env.npm_package_version || 'unknown',
+ region: process.env.VERCEL_REGION || 'unknown',
+ },
+ }
- // Send Discord notification asynchronously (don't await to avoid delaying the response)
+ if (overallStatus === 'unhealthy') {
sendDiscordNotification(responseData).catch(console.error)
-
- return NextResponse.json(responseData, {
- status: 500,
- headers: {
- 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
- Pragma: 'no-cache',
- Expires: '0',
- 'Surrogate-Control': 'no-store',
- },
- })
+ return NextResponse.json(responseData, { status: 500, headers: NO_CACHE_HEADERS })
}
- return NextResponse.json(
- {
- status: overallStatus,
- service: 'peanut-protocol',
- timestamp: new Date().toISOString(),
- responseTime: totalResponseTime,
- healthScore,
- summary: results.summary,
- services: results.services,
- systemInfo: {
- environment: process.env.NODE_ENV,
- version: process.env.npm_package_version || 'unknown',
- region: process.env.VERCEL_REGION || 'unknown',
- },
- },
- {
- status: 200,
- headers: {
- 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
- Pragma: 'no-cache',
- Expires: '0',
- 'Surrogate-Control': 'no-store',
- },
- }
- )
+ return NextResponse.json(responseData, { status: 200, headers: NO_CACHE_HEADERS })
} catch (error) {
const totalResponseTime = Date.now() - startTime
@@ -252,15 +243,7 @@ export async function GET() {
responseTime: totalResponseTime,
healthScore: 0,
},
- {
- status: 500,
- headers: {
- 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
- Pragma: 'no-cache',
- Expires: '0',
- 'Surrogate-Control': 'no-store',
- },
- }
+ { status: 500, headers: NO_CACHE_HEADERS }
)
}
}
diff --git a/src/app/api/health/rpc/route.ts b/src/app/api/health/rpc/route.ts
index 3f06a4196..59a57691c 100644
--- a/src/app/api/health/rpc/route.ts
+++ b/src/app/api/health/rpc/route.ts
@@ -1,4 +1,3 @@
-import { fetchWithSentry } from '@/utils/sentry.utils'
import { NextResponse } from 'next/server'
import { rpcUrls } from '@/constants/general.consts'
@@ -6,9 +5,18 @@ const INFURA_API_KEY = process.env.NEXT_PUBLIC_INFURA_API_KEY
const ALCHEMY_API_KEY = process.env.NEXT_PUBLIC_ALCHEMY_API_KEY
/**
- * Health check for RPC providers (Infura, Alchemy)
- * Tests connectivity across multiple chains
+ * Health check for RPC providers across key chains.
+ *
+ * Critical chains (Ethereum, Arbitrum) failing → system unhealthy.
+ * Non-critical chains (Polygon) failing → system degraded (not unhealthy).
+ *
+ * Uses plain fetch to avoid polluting Sentry with expected health check failures.
*/
+
+// Chains that must be healthy for the system to be considered healthy.
+// If a critical chain has zero healthy providers, overall status = unhealthy.
+const CRITICAL_CHAINS = new Set([1, 42161]) // Ethereum, Arbitrum
+
export async function GET() {
const startTime = Date.now()
@@ -28,130 +36,151 @@ export async function GET() {
const chainResults: any = {}
- // Test key chains: Ethereum mainnet, Arbitrum, Polygon
const chainsToTest = [
{ id: 1, name: 'ethereum' },
{ id: 42161, name: 'arbitrum' },
{ id: 137, name: 'polygon' },
]
- for (const chain of chainsToTest) {
- const chainRpcs = rpcUrls[chain.id] || []
- chainResults[chain.name] = {
- chainId: chain.id,
- providers: {},
- overallStatus: 'unknown',
- }
+ // Test all chains in parallel for faster response
+ await Promise.all(
+ chainsToTest.map(async (chain) => {
+ const chainRpcs = rpcUrls[chain.id] || []
+ chainResults[chain.name] = {
+ chainId: chain.id,
+ critical: CRITICAL_CHAINS.has(chain.id),
+ providers: {},
+ overallStatus: 'unknown',
+ }
- for (let i = 0; i < chainRpcs.length; i++) {
- const rpcUrl = chainRpcs[i]
- const providerName = rpcUrl.includes('infura')
- ? 'infura'
- : rpcUrl.includes('alchemy')
- ? 'alchemy'
- : rpcUrl.includes('bnbchain')
- ? 'binance'
- : `provider_${i}`
-
- const rpcTestStart = Date.now()
-
- try {
- const response = await fetchWithSentry(rpcUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- jsonrpc: '2.0',
- method: 'eth_blockNumber',
- params: [],
- id: 1,
- }),
+ // Test all providers for this chain in parallel
+ await Promise.all(
+ chainRpcs.map(async (rpcUrl, i) => {
+ const providerName = rpcUrl.includes('infura')
+ ? 'infura'
+ : rpcUrl.includes('alchemy')
+ ? 'alchemy'
+ : rpcUrl.includes('chainstack')
+ ? 'chainstack'
+ : rpcUrl.includes('publicnode')
+ ? 'publicnode'
+ : rpcUrl.includes('ankr')
+ ? 'ankr'
+ : rpcUrl.includes('bnbchain')
+ ? 'binance'
+ : `provider_${i}`
+
+ const rpcTestStart = Date.now()
+
+ try {
+ const response = await fetch(rpcUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ method: 'eth_blockNumber',
+ params: [],
+ id: 1,
+ }),
+ signal: AbortSignal.timeout(5000), // 5s per provider
+ })
+
+ const responseTime = Date.now() - rpcTestStart
+
+ if (response.ok) {
+ const data = await response.json()
+ const blockNumber = data?.result ? parseInt(data.result, 16) : null
+
+ chainResults[chain.name].providers[providerName] = {
+ status: blockNumber ? 'healthy' : 'degraded',
+ responseTime,
+ blockNumber,
+ url: sanitizeUrl(rpcUrl),
+ }
+ } else {
+ chainResults[chain.name].providers[providerName] = {
+ status: 'unhealthy',
+ responseTime,
+ httpStatus: response.status,
+ url: sanitizeUrl(rpcUrl),
+ }
+ }
+ } catch (error) {
+ chainResults[chain.name].providers[providerName] = {
+ status: 'unhealthy',
+ responseTime: Date.now() - rpcTestStart,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ url: sanitizeUrl(rpcUrl),
+ }
+ }
})
+ )
+
+ // Determine chain overall status
+ const chainProviders = Object.values(chainResults[chain.name].providers) as any[]
+ const healthyCount = chainProviders.filter((p) => p.status === 'healthy').length
+ const degradedCount = chainProviders.filter((p) => p.status === 'degraded').length
+ const unhealthyCount = chainProviders.length - healthyCount - degradedCount
+
+ if (healthyCount > 0) {
+ chainResults[chain.name].overallStatus = 'healthy'
+ } else if (degradedCount > 0) {
+ chainResults[chain.name].overallStatus = 'degraded'
+ } else {
+ chainResults[chain.name].overallStatus = 'unhealthy'
+ }
- const responseTime = Date.now() - rpcTestStart
-
- if (response.ok) {
- const data = await response.json()
- const blockNumber = data?.result ? parseInt(data.result, 16) : null
-
- chainResults[chain.name].providers[providerName] = {
- status: blockNumber ? 'healthy' : 'degraded',
- responseTime,
- blockNumber,
- url: rpcUrl.replace(/(api_key|api-key)=[^&]+/g, 'api_key=***'), // Hide API key
- }
- } else {
- chainResults[chain.name].providers[providerName] = {
- status: 'unhealthy',
- responseTime,
- httpStatus: response.status,
- url: rpcUrl.replace(/(api_key|api-key)=[^&]+/g, 'api_key=***'),
- }
- }
- } catch (error) {
- chainResults[chain.name].providers[providerName] = {
- status: 'unhealthy',
- responseTime: Date.now() - rpcTestStart,
- error: error instanceof Error ? error.message : 'Unknown error',
- url: rpcUrl.replace(/(api_key|api-key)=[^&]+/g, 'api_key=***'),
- }
+ chainResults[chain.name].summary = {
+ total: chainProviders.length,
+ healthy: healthyCount,
+ degraded: degradedCount,
+ unhealthy: unhealthyCount,
}
- }
+ })
+ )
- // Determine chain overall status
- const chainProviders = Object.values(chainResults[chain.name].providers)
- const healthyProviders = chainProviders.filter((p: any) => p.status === 'healthy')
- const degradedProviders = chainProviders.filter((p: any) => p.status === 'degraded')
-
- if (healthyProviders.length > 0) {
- chainResults[chain.name].overallStatus = 'healthy'
- } else if (degradedProviders.length > 0) {
- chainResults[chain.name].overallStatus = 'degraded'
- } else {
- chainResults[chain.name].overallStatus = 'unhealthy'
- }
+ // Determine overall status with critical vs non-critical distinction
+ let overallStatus = 'healthy'
+ let hasCriticalFailure = false
+ let hasNonCriticalFailure = false
- chainResults[chain.name].summary = {
- total: chainProviders.length,
- healthy: healthyProviders.length,
- degraded: degradedProviders.length,
- unhealthy: chainProviders.length - healthyProviders.length - degradedProviders.length,
+ for (const chain of chainsToTest) {
+ const status = chainResults[chain.name].overallStatus
+ if (status === 'unhealthy') {
+ if (CRITICAL_CHAINS.has(chain.id)) {
+ hasCriticalFailure = true
+ } else {
+ hasNonCriticalFailure = true
+ }
}
}
- // Determine overall RPC health
- const chainStatuses = Object.values(chainResults).map((chain: any) => chain.overallStatus)
- const hasUnhealthyChain = chainStatuses.includes('unhealthy')
- const hasDegradedChain = chainStatuses.includes('degraded')
-
- let overallStatus = 'healthy'
- if (hasUnhealthyChain) {
+ if (hasCriticalFailure) {
overallStatus = 'unhealthy'
- } else if (hasDegradedChain) {
+ } else if (hasNonCriticalFailure) {
overallStatus = 'degraded'
}
- // If any critical chain is unhealthy, return HTTP 500
- if (overallStatus === 'unhealthy') {
- throw new Error(`Critical RPC providers unavailable. Chains status: ${chainStatuses.join(', ')}`)
- }
-
const totalResponseTime = Date.now() - startTime
- return NextResponse.json({
- status: overallStatus,
- service: 'rpc',
- timestamp: new Date().toISOString(),
- responseTime: totalResponseTime,
- details: {
- chains: chainResults,
- configuration: {
- infuraConfigured: !!INFURA_API_KEY,
- alchemyConfigured: !!ALCHEMY_API_KEY,
+ const responseCode = overallStatus === 'unhealthy' ? 500 : 200
+
+ return NextResponse.json(
+ {
+ status: overallStatus,
+ service: 'rpc',
+ timestamp: new Date().toISOString(),
+ responseTime: totalResponseTime,
+ details: {
+ chains: chainResults,
+ configuration: {
+ infuraConfigured: !!INFURA_API_KEY,
+ alchemyConfigured: !!ALCHEMY_API_KEY,
+ },
},
},
- })
+ { status: responseCode }
+ )
} catch (error) {
const totalResponseTime = Date.now() - startTime
@@ -167,3 +196,12 @@ export async function GET() {
)
}
}
+
+/** Strip API keys from URLs for safe logging */
+function sanitizeUrl(url: string): string {
+ return url
+ .replace(/\/v3\/[a-f0-9]+/g, '/v3/***') // Infura
+ .replace(/\/v2\/[a-zA-Z0-9_-]+/g, '/v2/***') // Alchemy
+ .replace(/\/[a-f0-9]{32,}/g, '/***') // Chainstack and other hex keys
+ .replace(/(api_key|api-key|apikey)=[^&]+/g, '$1=***')
+}
diff --git a/src/app/api/health/zerodev/route.ts b/src/app/api/health/zerodev/route.ts
index de604d3dc..c360c8f1a 100644
--- a/src/app/api/health/zerodev/route.ts
+++ b/src/app/api/health/zerodev/route.ts
@@ -1,22 +1,26 @@
import { NextResponse } from 'next/server'
-import { fetchWithSentry } from '@/utils/sentry.utils'
/**
* ZeroDev health check endpoint
- * Tests bundler and paymaster services for supported chains
+ * Tests bundler and paymaster services for Arbitrum (the only chain using ZeroDev in production).
+ *
+ * Uses `eth_supportedEntryPoints` for the bundler — this is a mandatory ERC-4337 method
+ * that all compliant bundlers must support. Previous `eth_chainId` calls returned 400 on
+ * some bundlers since it's not part of the ERC-4337 spec.
+ *
+ * Paymaster is tested with `eth_chainId` and treats any response < 503 as healthy,
+ * since paymasters may return 400/500 for bare RPC calls while still being operational.
+ *
+ * Uses plain fetch (not fetchWithSentry) to avoid health check failures polluting Sentry.
*/
export async function GET() {
const startTime = Date.now()
try {
- // Get configuration from environment variables (same as zerodev.consts.ts)
const BUNDLER_URL = process.env.NEXT_PUBLIC_ZERO_DEV_BUNDLER_URL
const PAYMASTER_URL = process.env.NEXT_PUBLIC_ZERO_DEV_PAYMASTER_URL
const PROJECT_ID = process.env.NEXT_PUBLIC_ZERO_DEV_PASSKEY_PROJECT_ID
- const POLYGON_BUNDLER_URL = process.env.NEXT_PUBLIC_POLYGON_BUNDLER_URL
- const POLYGON_PAYMASTER_URL = process.env.NEXT_PUBLIC_POLYGON_PAYMASTER_URL
- // Check configuration
if (!BUNDLER_URL || !PAYMASTER_URL || !PROJECT_ID) {
return NextResponse.json(
{
@@ -31,46 +35,115 @@ export async function GET() {
}
const results: any = {
- arbitrum: {},
- polygon: {},
+ arbitrum: { bundler: {}, paymaster: {} },
configuration: {
- projectId: PROJECT_ID ? 'configured' : 'missing',
- bundlerUrl: BUNDLER_URL ? 'configured' : 'missing',
- paymasterUrl: PAYMASTER_URL ? 'configured' : 'missing',
- polygonBundlerUrl: POLYGON_BUNDLER_URL ? 'configured' : 'missing',
- polygonPaymasterUrl: POLYGON_PAYMASTER_URL ? 'configured' : 'missing',
+ projectId: 'configured',
+ bundlerUrl: 'configured',
+ paymasterUrl: 'configured',
},
}
- // Test Arbitrum endpoints
- await testChainEndpoints('arbitrum', BUNDLER_URL, PAYMASTER_URL, results)
+ // Test Arbitrum bundler with eth_supportedEntryPoints (mandatory ERC-4337 method)
+ const bundlerTestStart = Date.now()
+ try {
+ const bundlerResponse = await fetch(BUNDLER_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ signal: AbortSignal.timeout(5000),
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ method: 'eth_supportedEntryPoints',
+ params: [],
+ id: 1,
+ }),
+ })
+
+ const bundlerResponseTime = Date.now() - bundlerTestStart
+
+ // Any HTTP response means the bundler is reachable.
+ // 200 = fully healthy, 4xx = reachable but method issue (still alive), 5xx = server error
+ if (bundlerResponse.ok) {
+ const bundlerData = await bundlerResponse.json()
+ results.arbitrum.bundler = {
+ status: 'healthy',
+ responseTime: bundlerResponseTime,
+ httpStatus: bundlerResponse.status,
+ entryPoints: bundlerData?.result,
+ }
+ } else if (bundlerResponse.status < 500) {
+ // 4xx means the endpoint is reachable but rejected the call — degraded, not dead
+ results.arbitrum.bundler = {
+ status: 'degraded',
+ responseTime: bundlerResponseTime,
+ httpStatus: bundlerResponse.status,
+ message: 'Bundler reachable but returned client error',
+ }
+ } else {
+ results.arbitrum.bundler = {
+ status: 'unhealthy',
+ responseTime: bundlerResponseTime,
+ httpStatus: bundlerResponse.status,
+ }
+ }
+ } catch (error) {
+ results.arbitrum.bundler = {
+ status: 'unhealthy',
+ responseTime: Date.now() - bundlerTestStart,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ }
+ }
+
+ // Test Arbitrum paymaster
+ const paymasterTestStart = Date.now()
+ try {
+ const paymasterResponse = await fetch(PAYMASTER_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ signal: AbortSignal.timeout(5000),
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ method: 'eth_chainId',
+ params: [],
+ id: 1,
+ }),
+ })
+
+ const paymasterResponseTime = Date.now() - paymasterTestStart
+
+ // Paymaster often returns 400/500 for basic RPC calls — that's expected.
+ // Only mark unhealthy if we can't reach it at all (503+) or network error.
+ results.arbitrum.paymaster = {
+ status: paymasterResponse.status < 503 ? 'healthy' : 'unhealthy',
+ responseTime: paymasterResponseTime,
+ httpStatus: paymasterResponse.status,
+ }
- // Test Polygon endpoints (if configured)
- if (POLYGON_BUNDLER_URL && POLYGON_PAYMASTER_URL) {
- await testChainEndpoints('polygon', POLYGON_BUNDLER_URL, POLYGON_PAYMASTER_URL, results)
- } else {
- results.polygon = {
- status: 'not_configured',
- message: 'Polygon ZeroDev services not configured',
+ if (paymasterResponse.ok) {
+ const paymasterData = await paymasterResponse.json()
+ results.arbitrum.paymaster.chainId = paymasterData?.result
+ }
+ } catch (error) {
+ results.arbitrum.paymaster = {
+ status: 'unhealthy',
+ responseTime: Date.now() - paymasterTestStart,
+ error: error instanceof Error ? error.message : 'Unknown error',
}
}
- // Determine overall status
+ // Determine overall status — only Arbitrum matters for production
+ const bundlerOk =
+ results.arbitrum.bundler.status === 'healthy' || results.arbitrum.bundler.status === 'degraded'
+ const paymasterOk = results.arbitrum.paymaster.status === 'healthy'
+
let overallStatus = 'healthy'
- const arbitrumHealthy =
- results.arbitrum.bundler?.status === 'healthy' && results.arbitrum.paymaster?.status === 'healthy'
- const polygonHealthy =
- results.polygon.status === 'not_configured' ||
- (results.polygon.bundler?.status === 'healthy' && results.polygon.paymaster?.status === 'healthy')
-
- if (!arbitrumHealthy || !polygonHealthy) {
- // If any critical service is down, mark as unhealthy
+ if (!bundlerOk || !paymasterOk) {
overallStatus = 'unhealthy'
+ } else if (results.arbitrum.bundler.status === 'degraded') {
+ overallStatus = 'degraded'
}
const responseTime = Date.now() - startTime
- // Return 500 if unhealthy
if (overallStatus === 'unhealthy') {
return NextResponse.json(
{
@@ -92,92 +165,15 @@ export async function GET() {
details: results,
})
} catch (error) {
- const responseTime = Date.now() - startTime
-
return NextResponse.json(
{
status: 'unhealthy',
service: 'zerodev',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
- responseTime,
+ responseTime: Date.now() - startTime,
},
{ status: 500 }
)
}
}
-
-async function testChainEndpoints(chainName: string, bundlerUrl: string, paymasterUrl: string, results: any) {
- results[chainName] = {
- bundler: {},
- paymaster: {},
- }
-
- // Test Bundler - using a simple JSON-RPC call that bundlers should support
- const bundlerTestStart = Date.now()
- try {
- const bundlerResponse = await fetchWithSentry(bundlerUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- jsonrpc: '2.0',
- method: 'eth_chainId',
- params: [],
- id: 1,
- }),
- })
-
- results[chainName].bundler = {
- status: bundlerResponse.ok ? 'healthy' : 'unhealthy',
- responseTime: Date.now() - bundlerTestStart,
- httpStatus: bundlerResponse.status,
- }
-
- if (bundlerResponse.ok) {
- const bundlerData = await bundlerResponse.json()
- results[chainName].bundler.chainId = bundlerData?.result
- }
- } catch (error) {
- results[chainName].bundler = {
- status: 'unhealthy',
- responseTime: Date.now() - bundlerTestStart,
- error: error instanceof Error ? error.message : 'Unknown error',
- }
- }
-
- // Test Paymaster - using a simple JSON-RPC call
- const paymasterTestStart = Date.now()
- try {
- const paymasterResponse = await fetchWithSentry(paymasterUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- jsonrpc: '2.0',
- method: 'eth_chainId',
- params: [],
- id: 1,
- }),
- })
-
- results[chainName].paymaster = {
- status: paymasterResponse.status >= 200 && paymasterResponse.status < 503 ? 'healthy' : 'unhealthy', // 500 is expected for basic calls
- responseTime: Date.now() - paymasterTestStart,
- httpStatus: paymasterResponse.status,
- }
-
- if (paymasterResponse.ok) {
- const paymasterData = await paymasterResponse.json()
- results[chainName].paymaster.chainId = paymasterData?.result
- }
- } catch (error) {
- results[chainName].paymaster = {
- status: 'unhealthy',
- responseTime: Date.now() - paymasterTestStart,
- error: error instanceof Error ? error.message : 'Unknown error',
- }
- }
-}
diff --git a/src/app/crisp-proxy/page.tsx b/src/app/crisp-proxy/page.tsx
index 37e9c8c22..eea0814c0 100644
--- a/src/app/crisp-proxy/page.tsx
+++ b/src/app/crisp-proxy/page.tsx
@@ -18,16 +18,6 @@ import { CRISP_WEBSITE_ID } from '@/constants/crisp'
function CrispProxyContent() {
const searchParams = useSearchParams()
- useEffect(() => {
- if (typeof window !== 'undefined') {
- ;(window as any).CRISP_RUNTIME_CONFIG = {
- lock_maximized: true,
- lock_full_view: true,
- cross_origin_cookies: true, // Essential for session persistence in iframes
- }
- }
- }, [])
-
useEffect(() => {
if (typeof window === 'undefined') return
@@ -82,6 +72,7 @@ function CrispProxyContent() {
['wallet_address', data.wallet_address || ''],
['bridge_user_id', data.bridge_user_id || ''],
['manteca_user_id', data.manteca_user_id || ''],
+ ['posthog_person', data.posthog_person || ''],
],
]
window.$crisp.push(['set', 'session:data', sessionDataArray])
@@ -122,6 +113,7 @@ function CrispProxyContent() {
if (event.origin !== window.location.origin) return
if (event.data.type === 'CRISP_RESET_SESSION' && window.$crisp) {
+ window.CRISP_TOKEN_ID = null
window.$crisp.push(['do', 'session:reset'])
}
}
@@ -136,9 +128,14 @@ function CrispProxyContent() {
{`
window.$crisp=[];
window.CRISP_WEBSITE_ID="${CRISP_WEBSITE_ID}";
+ window.CRISP_RUNTIME_CONFIG={lock_maximized:true,lock_full_view:true,cross_origin_cookies:true,session_merge:true};
+ (function(){
+ var t=new URLSearchParams(window.location.search).get("crisp_token_id");
+ if(t) window.CRISP_TOKEN_ID=t;
+ })();
(function(){
- d=document;
- s=d.createElement("script");
+ var d=document;
+ var s=d.createElement("script");
s.src="https://client.crisp.chat/l.js";
s.async=1;
d.getElementsByTagName("head")[0].appendChild(s);
diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx
index 74e2eb5a7..aff8b9078 100644
--- a/src/components/Global/SupportDrawer/index.tsx
+++ b/src/components/Global/SupportDrawer/index.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react'
import { useModalsContext } from '@/context/ModalsContext'
import { useCrispUserData } from '@/hooks/useCrispUserData'
+import { useCrispTokenId } from '@/hooks/useCrispTokenId'
import { useCrispProxyUrl } from '@/hooks/useCrispProxyUrl'
import { Drawer, DrawerContent, DrawerTitle } from '../Drawer'
import PeanutLoading from '../PeanutLoading'
@@ -10,9 +11,10 @@ import PeanutLoading from '../PeanutLoading'
const SupportDrawer = () => {
const { isSupportModalOpen, setIsSupportModalOpen, supportPrefilledMessage: prefilledMessage } = useModalsContext()
const userData = useCrispUserData()
+ const crispTokenId = useCrispTokenId()
const [isLoading, setIsLoading] = useState(true)
- const crispProxyUrl = useCrispProxyUrl(userData, prefilledMessage)
+ const crispProxyUrl = useCrispProxyUrl(userData, prefilledMessage, crispTokenId)
useEffect(() => {
// Listen for ready message from proxy iframe
@@ -45,6 +47,8 @@ const SupportDrawer = () => {
)}
+ {/* TODO: Keep iframe alive between drawer open/close (hide with CSS instead of unmounting) */}
+ {/* to avoid re-initializing Crisp on every open. Currently causes noticeable load delay. */}