diff --git a/src/app/api/pinata/get-auth-key/route.ts b/src/app/api/pinata/get-auth-key/route.ts index bb88ce82..2b200e09 100644 --- a/src/app/api/pinata/get-auth-key/route.ts +++ b/src/app/api/pinata/get-auth-key/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { pinataClient } from "@/common/services/pinata"; export const dynamic = "force-dynamic"; +export const maxDuration = 15; /** * @link https://docs.pinata.cloud/frameworks/next-js-ipfs#create-api-route-2 diff --git a/src/app/api/pingpay/create-checkout/route.ts b/src/app/api/pingpay/create-checkout/route.ts index 3acabe88..3ca03efe 100644 --- a/src/app/api/pingpay/create-checkout/route.ts +++ b/src/app/api/pingpay/create-checkout/route.ts @@ -3,8 +3,10 @@ import { NextResponse } from "next/server"; import { CAMPAIGNS_CONTRACT_ACCOUNT_ID } from "@/common/_config"; export const dynamic = "force-dynamic"; +export const maxDuration = 15; const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api"; +const PINGPAY_TIMEOUT_MS = 10000; // TODO: move to env var and rotate before going public. const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk"; @@ -55,7 +57,7 @@ export async function POST(req: Request) { message: taggedMessage, }); - const response = await fetch(`${PINGPAY_API_BASE}/checkout/sessions`, { + const response = await fetchWithTimeout(`${PINGPAY_API_BASE}/checkout/sessions`, { method: "POST", headers: { "Content-Type": "application/json", @@ -98,3 +100,12 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Failed to create checkout" }, { status: 500 }); } } + +function fetchWithTimeout(input: string, init: RequestInit = {}) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), PINGPAY_TIMEOUT_MS); + + return fetch(input, { ...init, signal: controller.signal }).finally(() => { + clearTimeout(timeoutId); + }); +} diff --git a/src/app/api/pingpay/create-project-checkout/route.ts b/src/app/api/pingpay/create-project-checkout/route.ts index f6ffad55..1293b133 100644 --- a/src/app/api/pingpay/create-project-checkout/route.ts +++ b/src/app/api/pingpay/create-project-checkout/route.ts @@ -3,8 +3,10 @@ import { NextResponse } from "next/server"; import { DONATION_CONTRACT_ACCOUNT_ID } from "@/common/_config"; export const dynamic = "force-dynamic"; +export const maxDuration = 15; const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api"; +const PINGPAY_TIMEOUT_MS = 10000; // TODO: move to env var and rotate before going public. const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk"; @@ -56,7 +58,7 @@ export async function POST(req: Request) { message: taggedMessage, }); - const response = await fetch(`${PINGPAY_API_BASE}/checkout/sessions`, { + const response = await fetchWithTimeout(`${PINGPAY_API_BASE}/checkout/sessions`, { method: "POST", headers: { "Content-Type": "application/json", @@ -99,3 +101,12 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Failed to create checkout" }, { status: 500 }); } } + +function fetchWithTimeout(input: string, init: RequestInit = {}) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), PINGPAY_TIMEOUT_MS); + + return fetch(input, { ...init, signal: controller.signal }).finally(() => { + clearTimeout(timeoutId); + }); +} diff --git a/src/app/api/pingpay/session-status/route.ts b/src/app/api/pingpay/session-status/route.ts index 6350dab6..3ab394b5 100644 --- a/src/app/api/pingpay/session-status/route.ts +++ b/src/app/api/pingpay/session-status/route.ts @@ -1,8 +1,10 @@ import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; +export const maxDuration = 15; const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api"; +const PINGPAY_TIMEOUT_MS = 10000; // TODO: move to env var and rotate before going public. const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk"; @@ -22,10 +24,12 @@ export async function GET(req: Request) { return NextResponse.json({ error: "Missing sessionId" }, { status: 400 }); } - // Fetch session to get paymentId - const sessionRes = await fetch(`${PINGPAY_API_BASE}/checkout/sessions/${sessionId}`, { - headers: { "x-api-key": apiKey }, - }); + const sessionRes = await fetchWithTimeout( + `${PINGPAY_API_BASE}/checkout/sessions/${sessionId}`, + { + headers: { "x-api-key": apiKey }, + }, + ); if (!sessionRes.ok) { return NextResponse.json({ error: "Failed to fetch session" }, { status: sessionRes.status }); @@ -42,8 +46,7 @@ export async function GET(req: Request) { }); } - // Fetch payment to get txHash and sender - const paymentRes = await fetch(`${PINGPAY_API_BASE}/payments/${paymentId}`, { + const paymentRes = await fetchWithTimeout(`${PINGPAY_API_BASE}/payments/${paymentId}`, { headers: { "x-api-key": apiKey }, }); @@ -72,3 +75,12 @@ export async function GET(req: Request) { return NextResponse.json({ error: "Failed to fetch session status" }, { status: 500 }); } } + +function fetchWithTimeout(input: string, init: RequestInit = {}) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), PINGPAY_TIMEOUT_MS); + + return fetch(input, { ...init, signal: controller.signal }).finally(() => { + clearTimeout(timeoutId); + }); +} diff --git a/src/app/api/pingpay/verify-tx/route.ts b/src/app/api/pingpay/verify-tx/route.ts index b0ce0743..b4b0124c 100644 --- a/src/app/api/pingpay/verify-tx/route.ts +++ b/src/app/api/pingpay/verify-tx/route.ts @@ -3,10 +3,13 @@ import { NextResponse } from "next/server"; import { DONATION_CONTRACT_ACCOUNT_ID, NETWORK } from "@/common/_config"; export const dynamic = "force-dynamic"; +export const maxDuration = 15; const NEAR_RPC_URL = NETWORK === "mainnet" ? "https://rpc.mainnet.near.org" : "https://rpc.testnet.near.org"; +const NEAR_RPC_TIMEOUT_MS = 10000; + /** * Confirms a PingPay-sourced donation actually landed on-chain (not refunded). * @@ -29,7 +32,7 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Missing txHash or senderId" }, { status: 400 }); } - const rpcRes = await fetch(NEAR_RPC_URL, { + const rpcRes = await fetchWithTimeout(NEAR_RPC_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -130,3 +133,12 @@ function findDonationLog(receipts: any[], expectedRecipient?: string): any | nul return null; } + +function fetchWithTimeout(input: string, init: RequestInit = {}) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), NEAR_RPC_TIMEOUT_MS); + + return fetch(input, { ...init, signal: controller.signal }).finally(() => { + clearTimeout(timeoutId); + }); +} diff --git a/src/app/pingpay/callback/page.tsx b/src/app/pingpay/callback/page.tsx index f2dc368d..fc98e711 100644 --- a/src/app/pingpay/callback/page.tsx +++ b/src/app/pingpay/callback/page.tsx @@ -111,11 +111,13 @@ async function pollSessionStatus(sessionId: string): Promise<{ txHash: string | null; senderId: string | null; } | null> { - // 60 attempts × 2 s = 120 s. PingPay's relayer can take >30 s to submit the - // on-chain tx, especially for first-ever donations to a recipient. The old - // 30 s window silently dropped the sync when settlement was slow. - const maxAttempts = 60; - const delayMs = 2000; + // Keep a long settlement window, but back off so each checkout does not keep + // a Vercel function hot with dozens of identical status calls. + const delaysMs = [ + ...Array.from({ length: 5 }, () => 2000), + ...Array.from({ length: 8 }, () => 5000), + ...Array.from({ length: 8 }, () => 10000), + ]; let lastData: { status: string; @@ -123,7 +125,7 @@ async function pollSessionStatus(sessionId: string): Promise<{ senderId: string | null; } | null = null; - for (let i = 0; i < maxAttempts; i++) { + for (let i = 0; i <= delaysMs.length; i++) { try { const res = await fetch( `/api/pingpay/session-status?sessionId=${encodeURIComponent(sessionId)}`, @@ -143,7 +145,8 @@ async function pollSessionStatus(sessionId: string): Promise<{ // retry } - await new Promise((r) => setTimeout(r, delayMs)); + const delayMs = delaysMs[i]; + if (delayMs) await new Promise((r) => setTimeout(r, delayMs)); } return lastData; diff --git a/src/common/blockchains/near-protocol/client.ts b/src/common/blockchains/near-protocol/client.ts index 918c7ea4..8f94a981 100644 --- a/src/common/blockchains/near-protocol/client.ts +++ b/src/common/blockchains/near-protocol/client.ts @@ -7,13 +7,87 @@ import type { QueryResponseKind, } from "@near-js/types/lib/provider/response"; import { providers } from "near-api-js"; +import type { CodeResult } from "near-api-js/lib/providers/provider"; import { NETWORK, SOCIAL_DB_CONTRACT_ACCOUNT_ID } from "@/common/_config"; import { FULL_TGAS } from "@/common/constants"; -export const RPC_NODE_URL = `https://${NETWORK === "mainnet" ? "free.rpc.fastnear.com" : "test.rpc.fastnear.com"}`; +const RPC_NODE_URLS = + NETWORK === "mainnet" + ? [ + "https://1rpc.io/near", + "https://free.rpc.fastnear.com", + "https://near.blockpi.network/v1/rpc/public", + "https://near.lava.build", + "https://rpc.ankr.com/near", + ] + : ["https://rpc.testnet.near.org", "https://test.rpc.fastnear.com"]; -const nearRpc = new providers.JsonRpcProvider({ url: RPC_NODE_URL }); +export const RPC_NODE_URL = RPC_NODE_URLS[0]; + +const RPC_COOLDOWN_MS = 60_000; + +const rpcCooldownUntil = new Map(); + +const rpcProviders = RPC_NODE_URLS.map((url) => ({ + url, + provider: new providers.JsonRpcProvider({ url }), +})); + +let preferredRpcIndex = 0; + +const getRpcProviderOrder = () => { + const indexes = rpcProviders.map((_, index) => index); + + return [preferredRpcIndex, ...indexes.filter((index) => index !== preferredRpcIndex)]; +}; + +const markRpcUnavailable = (url: string) => { + rpcCooldownUntil.set(url, Date.now() + RPC_COOLDOWN_MS); +}; + +const isRpcAvailable = (url: string) => (rpcCooldownUntil.get(url) ?? 0) <= Date.now(); + +type NearRpcProvider = InstanceType; + +const queryNearRpc = async (run: (provider: NearRpcProvider) => Promise): Promise => { + let lastError: unknown; + + for (const index of getRpcProviderOrder()) { + const { provider, url } = rpcProviders[index]; + + if (!isRpcAvailable(url)) continue; + + try { + const result = await run(provider); + preferredRpcIndex = index; + return result; + } catch (error) { + lastError = error; + markRpcUnavailable(url); + } + } + + const fallbackIndex = preferredRpcIndex === 0 ? 1 : 0; + const fallback = rpcProviders[fallbackIndex] ?? rpcProviders[0]; + + try { + const result = await run(fallback.provider); + preferredRpcIndex = fallbackIndex; + return result; + } catch (error) { + throw lastError ?? error; + } +}; + +const nearRpc = { + query: ( + ...args: Parameters + ) => queryNearRpc((provider) => provider.query(...args)), + + txStatus: (...args: Parameters) => + queryNearRpc((provider) => provider.txStatus(...args)), +}; type CallProps> = { args?: A; diff --git a/src/middleware.ts b/src/middleware.ts index bfb091cc..4f7ade43 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -40,3 +40,7 @@ export async function middleware(request: NextRequest) { return NextResponse.next(); } + +export const config = { + matcher: ["/profile/:path*"], +}; diff --git a/src/pages/campaign/[campaignId]/index.tsx b/src/pages/campaign/[campaignId]/index.tsx index d3bf9698..f3bf66f9 100644 --- a/src/pages/campaign/[campaignId]/index.tsx +++ b/src/pages/campaign/[campaignId]/index.tsx @@ -3,7 +3,6 @@ import { ReactElement, useMemo } from "react"; import type { GetStaticPaths, GetStaticProps } from "next"; import { useRouter } from "next/router"; -import { INDEXER_API_ENDPOINT_URL } from "@/common/_config"; import { APP_METADATA } from "@/common/constants"; import { CampaignDonorsTable, CampaignSettings } from "@/entities/campaign"; import { CampaignLayout } from "@/layout/campaign/components/layout"; @@ -120,9 +119,9 @@ export const getStaticProps: GetStaticProps = async (context) image: campaign?.cover_image_url ?? fallbackSeo.image, }; - return { props: { seo }, revalidate: 300 }; + return { props: { seo }, revalidate: 3600 }; } catch { - return { props: { seo: fallbackSeo }, revalidate: 60 }; + return { props: { seo: fallbackSeo }, revalidate: 600 }; } finally { clearTimeout(timeoutId); } diff --git a/src/pages/robots.txt.ts b/src/pages/robots.txt.ts index cad91ba4..dbb8f3bf 100644 --- a/src/pages/robots.txt.ts +++ b/src/pages/robots.txt.ts @@ -26,7 +26,7 @@ ${sitemapLines} `; res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.setHeader("Cache-Control", "s-maxage=1800, stale-while-revalidate=3600"); + res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=86400"); res.write(robots); res.end(); diff --git a/src/pages/sitemap.xml.ts b/src/pages/sitemap.xml.ts index 2a3ea722..a72a3f0a 100644 --- a/src/pages/sitemap.xml.ts +++ b/src/pages/sitemap.xml.ts @@ -66,7 +66,7 @@ ${urls `; res.setHeader("Content-Type", "application/xml"); - res.setHeader("Cache-Control", "s-maxage=1800, stale-while-revalidate=3600"); + res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=86400"); res.write(xml); res.end(); } catch { @@ -84,7 +84,7 @@ ${urls res.statusCode = 200; res.setHeader("Content-Type", "application/xml"); - res.setHeader("Cache-Control", "s-maxage=600, stale-while-revalidate=1200"); + res.setHeader("Cache-Control", "s-maxage=3600, stale-while-revalidate=86400"); res.end(xml); }