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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/api/pinata/get-auth-key/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion src/app/api/pingpay/create-checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);
});
}
13 changes: 12 additions & 1 deletion src/app/api/pingpay/create-project-checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);
});
}
24 changes: 18 additions & 6 deletions src/app/api/pingpay/session-status/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 });
Expand All @@ -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 },
});

Expand Down Expand Up @@ -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);
});
}
14 changes: 13 additions & 1 deletion src/app/api/pingpay/verify-tx/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand All @@ -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({
Expand Down Expand Up @@ -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);
});
}
17 changes: 10 additions & 7 deletions src/app/pingpay/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,19 +111,21 @@ 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;
txHash: string | null;
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)}`,
Expand All @@ -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;
Expand Down
78 changes: 76 additions & 2 deletions src/common/blockchains/near-protocol/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();

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<typeof providers.JsonRpcProvider>;

const queryNearRpc = async <R>(run: (provider: NearRpcProvider) => Promise<R>): Promise<R> => {
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: <R extends QueryResponseKind = CodeResult>(
...args: Parameters<NearRpcProvider["query"]>
) => queryNearRpc((provider) => provider.query<R>(...args)),

txStatus: (...args: Parameters<NearRpcProvider["txStatus"]>) =>
queryNearRpc((provider) => provider.txStatus(...args)),
};

type CallProps<A extends object = Record<string, unknown>> = {
args?: A;
Expand Down
4 changes: 4 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,7 @@ export async function middleware(request: NextRequest) {

return NextResponse.next();
}

export const config = {
matcher: ["/profile/:path*"],
};
5 changes: 2 additions & 3 deletions src/pages/campaign/[campaignId]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -120,9 +119,9 @@ export const getStaticProps: GetStaticProps<CampaignPageProps> = 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);
}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/robots.txt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
4 changes: 2 additions & 2 deletions src/pages/sitemap.xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ ${urls
</urlset>`;

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 {
Expand All @@ -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);
}

Expand Down
Loading