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. */}