diff --git a/scripts/compare-rates.mjs b/scripts/compare-rates.mjs new file mode 100644 index 000000000..660568ce4 --- /dev/null +++ b/scripts/compare-rates.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +// Compare Bridge vs Frankfurter (and optionally local /api/exchange-rate) rates +// Usage examples: +// node scripts/compare-rates.mjs +// node scripts/compare-rates.mjs --pairs USD:EUR,USD:MXN --api http://localhost:3000/api/exchange-rate +// BRIDGE_API_KEY=xxx node scripts/compare-rates.mjs + +import { readFileSync } from 'fs' +import { resolve } from 'path' + +// Load .env files +function loadEnv() { + const envFiles = ['.env.local', '.env'] + for (const file of envFiles) { + try { + const envPath = resolve(file) + const envContent = readFileSync(envPath, 'utf8') + envContent.split('\n').forEach((line) => { + const trimmed = line.trim() + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...valueParts] = trimmed.split('=') + if (key && valueParts.length > 0) { + const value = valueParts.join('=').replace(/^["']|["']$/g, '') + if (!process.env[key]) { + process.env[key] = value + } + } + } + }) + console.log(`Loaded environment from ${file}`) + break + } catch (e) { + // File doesn't exist, continue to next + } + } +} + +loadEnv() + +const DEFAULT_PAIRS = [ + ['USD', 'EUR'], + ['USD', 'MXN'], + ['USD', 'BRL'], + ['EUR', 'USD'], + ['EUR', 'GBP'], +] + +const params = process.argv.slice(2) +const pairsArg = getArg('--pairs') +const apiArg = getArg('--api') + +const PAIRS = pairsArg ? pairsArg.split(',').map((p) => p.split(':').map((s) => s.trim().toUpperCase())) : DEFAULT_PAIRS + +const BRIDGE_API_KEY = process.env.BRIDGE_API_KEY + +function getArg(name) { + const i = params.indexOf(name) + if (i === -1) return null + return params[i + 1] || null +} + +async function fetchBridge(from, to) { + if (!BRIDGE_API_KEY) { + return { error: 'Missing BRIDGE_API_KEY' } + } + const url = `https://api.bridge.xyz/v0/exchange_rates?from=${from.toLowerCase()}&to=${to.toLowerCase()}` + const res = await fetch(url, { + method: 'GET', + headers: { 'Api-Key': BRIDGE_API_KEY }, + }) + if (!res.ok) { + return { error: `${res.status} ${res.statusText}` } + } + const data = await res.json() + const { midmarket_rate, buy_rate, sell_rate } = data || {} + return { midmarket_rate, buy_rate, sell_rate } +} + +async function fetchFrankfurter(from, to) { + const url = `https://api.frankfurter.app/latest?from=${from}&to=${to}` + const res = await fetch(url, { method: 'GET' }) + if (!res.ok) { + return { error: `${res.status} ${res.statusText}` } + } + const data = await res.json() + const rate = data?.rates?.[to] + return { rate, rate_995: typeof rate === 'number' ? rate * 0.995 : undefined } +} + +async function fetchLocalApi(from, to) { + if (!apiArg) return {} + try { + const url = `${apiArg}?from=${from}&to=${to}` + const res = await fetch(url, { method: 'GET' }) + if (!res.ok) { + return { error: `${res.status} ${res.statusText}` } + } + const data = await res.json() + return { rate: data?.rate } + } catch (error) { + return { error: `Connection failed: ${error.message}` } + } +} + +function fmt(n, digits = 6) { + return typeof n === 'number' && Number.isFinite(n) ? n.toFixed(digits) : '-' +} + +function bps(a, b) { + if (typeof a !== 'number' || typeof b !== 'number' || !Number.isFinite(a) || !Number.isFinite(b) || b === 0) + return '-' + const rel = (a / b - 1) * 10000 + return `${rel.toFixed(1)} bps` +} + +async function run() { + console.log('Comparing rates...') + if (!BRIDGE_API_KEY) { + console.warn('Warning: BRIDGE_API_KEY not set. Bridge calls will be skipped or return errors.') + } + if (apiArg) { + console.log(`Also querying local API: ${apiArg}`) + } + + for (const [from, to] of PAIRS) { + const [bridge, frankData, local] = await Promise.all([ + fetchBridge(from, to).catch((e) => ({ error: e?.message || String(e) })), + fetchFrankfurter(from, to).catch((e) => ({ error: e?.message || String(e) })), + fetchLocalApi(from, to).catch((e) => ({ error: e?.message || String(e) })), + ]) + + const bridgeBuy = bridge?.buy_rate ? Number(bridge.buy_rate) : undefined + const bridgeMid = bridge?.midmarket_rate ? Number(bridge.midmarket_rate) : undefined + const bridgeSell = bridge?.sell_rate ? Number(bridge.sell_rate) : undefined + const frank = typeof frankData?.rate === 'number' ? frankData.rate : undefined + const frank995 = typeof frankData?.rate_995 === 'number' ? frankData.rate_995 : undefined + const localRate = typeof local?.rate === 'number' ? local.rate : undefined + + console.log(`\nPair: ${from} -> ${to}`) + console.table([ + { + source: 'Bridge', + buy: fmt(bridgeBuy), + mid: fmt(bridgeMid), + sell: fmt(bridgeSell), + note: bridge?.error || '', + }, + { + source: 'Frankfurter', + rate: fmt(frank), + rate_995: fmt(frank995), + note: frankData?.error || '', + }, + { + source: 'Local API', + rate: fmt(localRate), + note: local?.error || '', + }, + ]) + + // Delta analysis table + console.log(`\nDelta Analysis for ${from} -> ${to}:`) + console.table([ + { + comparison: 'Mid vs Frankfurt', + delta: bps(bridgeMid, frank), + }, + { + comparison: 'Mid vs Frankfurt×0.995', + delta: bps(bridgeMid, frank995), + }, + { + comparison: 'Sell vs Frankfurt×0.995', + delta: bps(bridgeSell, frank995), + }, + { + comparison: 'Sell vs Mid', + delta: bps(bridgeSell, bridgeMid), + }, + { + comparison: 'Buy vs Mid', + delta: bps(bridgeBuy, bridgeMid), + }, + { + comparison: 'Local vs Frankfurt×0.995', + delta: bps(localRate, frank995), + }, + ]) + } +} + +run().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/src/app/(mobile-ui)/claim/page.tsx b/src/app/(mobile-ui)/claim/page.tsx index 558d96264..ddfe5dacc 100644 --- a/src/app/(mobile-ui)/claim/page.tsx +++ b/src/app/(mobile-ui)/claim/page.tsx @@ -97,7 +97,7 @@ export async function generateMetadata({ description, ...(siteUrl ? { metadataBase: new URL(siteUrl) } : {}), icons: { - icon: '/logo-favicon.png', + icon: '/favicon.ico', }, openGraph: { title, diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 195e41fee..295bad12d 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -38,8 +38,8 @@ import { AccountType } from '@/interfaces' import { formatUnits } from 'viem' import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants' import { PostSignupActionManager } from '@/components/Global/PostSignupActionManager' -import { useGuestFlow } from '@/context/GuestFlowContext' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' +import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' const BALANCE_WARNING_THRESHOLD = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_THRESHOLD ?? '500') const BALANCE_WARNING_EXPIRY = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_EXPIRY ?? '1814400') // 21 days in seconds @@ -48,7 +48,7 @@ export default function Home() { const { balance, address, isFetchingBalance, isFetchingRewardBalance } = useWallet() const { rewardWalletBalance } = useWalletStore() const [isRewardsModalOpen, setIsRewardsModalOpen] = useState(false) - const { resetGuestFlow } = useGuestFlow() + const { resetFlow: resetClaimBankFlow } = useClaimBankFlow() const { resetWithdrawFlow } = useWithdrawFlow() const [isBalanceHidden, setIsBalanceHidden] = useState(() => { const prefs = getUserPreferences() @@ -84,9 +84,9 @@ export default function Home() { const isLoading = isFetchingUser && !username useEffect(() => { - resetGuestFlow() + resetClaimBankFlow() resetWithdrawFlow() - }, [resetGuestFlow, resetWithdrawFlow]) + }, [resetClaimBankFlow, resetWithdrawFlow]) useEffect(() => { // We have some users that didn't have the peanut wallet created diff --git a/src/app/(mobile-ui)/request/pay/page.tsx b/src/app/(mobile-ui)/request/pay/page.tsx index 4c1c83e24..531c60630 100644 --- a/src/app/(mobile-ui)/request/pay/page.tsx +++ b/src/app/(mobile-ui)/request/pay/page.tsx @@ -68,7 +68,7 @@ export async function generateMetadata({ title, description: 'Request cryptocurrency from friends, family, or anyone else using Peanut on any chain.', icons: { - icon: '/logo-favicon.png', + icon: '/favicon.ico', }, openGraph: { images: [ diff --git a/src/app/[...recipient]/page.tsx b/src/app/[...recipient]/page.tsx index 17777a3be..a1dcf33b1 100644 --- a/src/app/[...recipient]/page.tsx +++ b/src/app/[...recipient]/page.tsx @@ -137,7 +137,7 @@ export async function generateMetadata({ params, searchParams }: any) { description, ...(siteUrl ? { metadataBase: new URL(siteUrl) } : {}), icons: { - icon: '/logo-favicon.png', + icon: '/favicon.ico', }, openGraph: { title, diff --git a/src/app/actions/squid.ts b/src/app/actions/squid.ts index 6d21ffb82..295d7c071 100644 --- a/src/app/actions/squid.ts +++ b/src/app/actions/squid.ts @@ -34,16 +34,17 @@ const getSquidTokensCache = unstable_cache( ) export const getSquidChainsAndTokens = unstable_cache( - async (): Promise> => { + async (): Promise< + Record + > => { const [chains, tokens] = await Promise.all([getSquidChainsCache(), getSquidTokensCache()]) - const chainsById = chains.reduce>( - (acc, chain) => { - acc[chain.chainId] = { ...chain, tokens: [] } - return acc - }, - {} - ) + const chainsById = chains.reduce< + Record + >((acc, chain) => { + acc[chain.chainId] = { ...(chain as interfaces.ISquidChain & { networkName: string }), tokens: [] } + return acc + }, {}) tokens.forEach((token) => { if (token.active && token.chainId in chainsById) { diff --git a/src/app/api/exchange-rate/route.ts b/src/app/api/exchange-rate/route.ts new file mode 100644 index 000000000..459f5b803 --- /dev/null +++ b/src/app/api/exchange-rate/route.ts @@ -0,0 +1,160 @@ +import { NextRequest, NextResponse } from 'next/server' + +interface ExchangeRateResponse { + rate: number +} + +interface BridgeExchangeRateResponse { + midmarket_rate: string + buy_rate: string + sell_rate: string +} + +// Currency pairs that should use Bridge API (USD to these currencies only) +const BRIDGE_PAIRS = new Set(['USD-EUR', 'USD-MXN', 'USD-BRL']) + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const from = searchParams.get('from') + const to = searchParams.get('to') + + // Validate required parameters + if (!from || !to) { + return NextResponse.json({ error: 'Missing required parameters: from and to' }, { status: 400 }) + } + + const fromUc = from.toUpperCase() + const toUc = to.toUpperCase() + + // Same-currency pair: return 1:1 immediately + if (fromUc === toUc) { + return NextResponse.json( + { rate: 1 }, + { + headers: { + 'Cache-Control': 's-maxage=300, stale-while-revalidate=600', + }, + } + ) + } + + const pairKey = `${fromUc}-${toUc}` + const reversePairKey = `${toUc}-${fromUc}` + + // Check if we should use Bridge for this pair or its reverse + const shouldUseBridge = BRIDGE_PAIRS.has(pairKey) + const shouldUseBridgeReverse = BRIDGE_PAIRS.has(reversePairKey) + + if (shouldUseBridge || shouldUseBridgeReverse) { + // For Bridge pairs, we need to determine which rate to use + let bridgeResult + if (shouldUseBridge) { + // Direct pair (e.g., USD→EUR): use sell_rate + bridgeResult = await fetchFromBridge(fromUc, toUc, 'sell_rate', false) + } else { + // Reverse pair (e.g., EUR→USD): fetch USD→EUR and use buy_rate, then invert + bridgeResult = await fetchFromBridge(toUc, fromUc, 'buy_rate', true) + } + + if (bridgeResult) { + return bridgeResult + } + // Fall back to Frankfurter if Bridge fails + console.warn(`Bridge failed for ${pairKey}, falling back to Frankfurter`) + } + + // Use Frankfurter for all other pairs or as fallback + return await fetchFromFrankfurter(fromUc, toUc) + } catch (error) { + console.error('Exchange rate API error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +async function fetchFromBridge( + from: string, + to: string, + rateType: 'buy_rate' | 'sell_rate', + shouldInvert: boolean +): Promise { + const bridgeAPIKey = process.env.BRIDGE_API_KEY + + if (!bridgeAPIKey) { + console.warn('Bridge API key not set') + return null + } + + try { + const url = `https://api.bridge.xyz/v0/exchange_rates?from=${from.toLowerCase()}&to=${to.toLowerCase()}` + const options: RequestInit & { next?: { revalidate?: number } } = { + method: 'GET', + // Bridge expects header name 'Api-Key' + headers: { 'Api-Key': bridgeAPIKey }, + next: { revalidate: 300 }, // Cache for 5 minutes + } + + const response = await fetch(url, options) + + if (!response.ok) { + console.error(`Bridge API error: ${response.status} ${response.statusText}`) + return null + } + + const bridgeData: BridgeExchangeRateResponse = await response.json() + + // Validate the response structure + if (!bridgeData[rateType]) { + console.error(`Invalid Bridge response: missing ${rateType}`) + return null + } + + let rate = parseFloat(bridgeData[rateType]) + + // If we fetched the reverse pair (e.g., fetched USD→EUR for EUR→USD request), + // we need to invert the rate + if (shouldInvert) { + rate = 1 / rate + } + + const exchangeRate: ExchangeRateResponse = { + rate, + } + + return NextResponse.json(exchangeRate, { + headers: { + 'Cache-Control': 's-maxage=300, stale-while-revalidate=600', + }, + }) + } catch (error) { + console.error('Bridge API exception:', error) + return null + } +} + +async function fetchFromFrankfurter(from: string, to: string): Promise { + const url = `https://api.frankfurter.app/latest?from=${from}&to=${to}` + const options: RequestInit & { next?: { revalidate?: number } } = { + method: 'GET', + next: { revalidate: 300 }, // Cache for 5 minutes + } + + const response = await fetch(url, options) + + if (!response.ok) { + console.error(`Frankfurter API error: ${response.status} ${response.statusText}`) + return NextResponse.json({ error: 'Failed to fetch exchange rates from API' }, { status: response.status }) + } + + const data = await response.json() + + const exchangeRate: ExchangeRateResponse = { + rate: data.rates[to] * 0.995, // Subtract 50bps + } + + return NextResponse.json(exchangeRate, { + headers: { + 'Cache-Control': 's-maxage=300, stale-while-revalidate=600', + }, + }) +} diff --git a/src/app/exchange/page.tsx b/src/app/exchange/page.tsx new file mode 100644 index 000000000..49143ff6f --- /dev/null +++ b/src/app/exchange/page.tsx @@ -0,0 +1,13 @@ +'use client' + +import Layout from '@/components/Global/Layout' +import { NoFees } from '@/components/LandingPage' +import Footer from '@/components/LandingPage/Footer' +export default function ExchangePage() { + return ( + + +