Skip to content

Commit 5d38375

Browse files
authored
Merge pull request #626 from PotLock/billing-fix
fixed billing issues
2 parents f2d8fe9 + ec9df77 commit 5d38375

11 files changed

Lines changed: 151 additions & 24 deletions

File tree

src/app/api/pinata/get-auth-key/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
33
import { pinataClient } from "@/common/services/pinata";
44

55
export const dynamic = "force-dynamic";
6+
export const maxDuration = 15;
67

78
/**
89
* @link https://docs.pinata.cloud/frameworks/next-js-ipfs#create-api-route-2

src/app/api/pingpay/create-checkout/route.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { NextResponse } from "next/server";
33
import { CAMPAIGNS_CONTRACT_ACCOUNT_ID } from "@/common/_config";
44

55
export const dynamic = "force-dynamic";
6+
export const maxDuration = 15;
67

78
const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api";
9+
const PINGPAY_TIMEOUT_MS = 10000;
810

911
// TODO: move to env var and rotate before going public.
1012
const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk";
@@ -55,7 +57,7 @@ export async function POST(req: Request) {
5557
message: taggedMessage,
5658
});
5759

58-
const response = await fetch(`${PINGPAY_API_BASE}/checkout/sessions`, {
60+
const response = await fetchWithTimeout(`${PINGPAY_API_BASE}/checkout/sessions`, {
5961
method: "POST",
6062
headers: {
6163
"Content-Type": "application/json",
@@ -98,3 +100,12 @@ export async function POST(req: Request) {
98100
return NextResponse.json({ error: "Failed to create checkout" }, { status: 500 });
99101
}
100102
}
103+
104+
function fetchWithTimeout(input: string, init: RequestInit = {}) {
105+
const controller = new AbortController();
106+
const timeoutId = setTimeout(() => controller.abort(), PINGPAY_TIMEOUT_MS);
107+
108+
return fetch(input, { ...init, signal: controller.signal }).finally(() => {
109+
clearTimeout(timeoutId);
110+
});
111+
}

src/app/api/pingpay/create-project-checkout/route.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { NextResponse } from "next/server";
33
import { DONATION_CONTRACT_ACCOUNT_ID } from "@/common/_config";
44

55
export const dynamic = "force-dynamic";
6+
export const maxDuration = 15;
67

78
const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api";
9+
const PINGPAY_TIMEOUT_MS = 10000;
810

911
// TODO: move to env var and rotate before going public.
1012
const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk";
@@ -56,7 +58,7 @@ export async function POST(req: Request) {
5658
message: taggedMessage,
5759
});
5860

59-
const response = await fetch(`${PINGPAY_API_BASE}/checkout/sessions`, {
61+
const response = await fetchWithTimeout(`${PINGPAY_API_BASE}/checkout/sessions`, {
6062
method: "POST",
6163
headers: {
6264
"Content-Type": "application/json",
@@ -99,3 +101,12 @@ export async function POST(req: Request) {
99101
return NextResponse.json({ error: "Failed to create checkout" }, { status: 500 });
100102
}
101103
}
104+
105+
function fetchWithTimeout(input: string, init: RequestInit = {}) {
106+
const controller = new AbortController();
107+
const timeoutId = setTimeout(() => controller.abort(), PINGPAY_TIMEOUT_MS);
108+
109+
return fetch(input, { ...init, signal: controller.signal }).finally(() => {
110+
clearTimeout(timeoutId);
111+
});
112+
}

src/app/api/pingpay/session-status/route.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { NextResponse } from "next/server";
22

33
export const dynamic = "force-dynamic";
4+
export const maxDuration = 15;
45

56
const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api";
7+
const PINGPAY_TIMEOUT_MS = 10000;
68

79
// TODO: move to env var and rotate before going public.
810
const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk";
@@ -22,10 +24,12 @@ export async function GET(req: Request) {
2224
return NextResponse.json({ error: "Missing sessionId" }, { status: 400 });
2325
}
2426

25-
// Fetch session to get paymentId
26-
const sessionRes = await fetch(`${PINGPAY_API_BASE}/checkout/sessions/${sessionId}`, {
27-
headers: { "x-api-key": apiKey },
28-
});
27+
const sessionRes = await fetchWithTimeout(
28+
`${PINGPAY_API_BASE}/checkout/sessions/${sessionId}`,
29+
{
30+
headers: { "x-api-key": apiKey },
31+
},
32+
);
2933

3034
if (!sessionRes.ok) {
3135
return NextResponse.json({ error: "Failed to fetch session" }, { status: sessionRes.status });
@@ -42,8 +46,7 @@ export async function GET(req: Request) {
4246
});
4347
}
4448

45-
// Fetch payment to get txHash and sender
46-
const paymentRes = await fetch(`${PINGPAY_API_BASE}/payments/${paymentId}`, {
49+
const paymentRes = await fetchWithTimeout(`${PINGPAY_API_BASE}/payments/${paymentId}`, {
4750
headers: { "x-api-key": apiKey },
4851
});
4952

@@ -72,3 +75,12 @@ export async function GET(req: Request) {
7275
return NextResponse.json({ error: "Failed to fetch session status" }, { status: 500 });
7376
}
7477
}
78+
79+
function fetchWithTimeout(input: string, init: RequestInit = {}) {
80+
const controller = new AbortController();
81+
const timeoutId = setTimeout(() => controller.abort(), PINGPAY_TIMEOUT_MS);
82+
83+
return fetch(input, { ...init, signal: controller.signal }).finally(() => {
84+
clearTimeout(timeoutId);
85+
});
86+
}

src/app/api/pingpay/verify-tx/route.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import { NextResponse } from "next/server";
33
import { DONATION_CONTRACT_ACCOUNT_ID, NETWORK } from "@/common/_config";
44

55
export const dynamic = "force-dynamic";
6+
export const maxDuration = 15;
67

78
const NEAR_RPC_URL =
89
NETWORK === "mainnet" ? "https://rpc.mainnet.near.org" : "https://rpc.testnet.near.org";
910

11+
const NEAR_RPC_TIMEOUT_MS = 10000;
12+
1013
/**
1114
* Confirms a PingPay-sourced donation actually landed on-chain (not refunded).
1215
*
@@ -29,7 +32,7 @@ export async function POST(req: Request) {
2932
return NextResponse.json({ error: "Missing txHash or senderId" }, { status: 400 });
3033
}
3134

32-
const rpcRes = await fetch(NEAR_RPC_URL, {
35+
const rpcRes = await fetchWithTimeout(NEAR_RPC_URL, {
3336
method: "POST",
3437
headers: { "Content-Type": "application/json" },
3538
body: JSON.stringify({
@@ -130,3 +133,12 @@ function findDonationLog(receipts: any[], expectedRecipient?: string): any | nul
130133

131134
return null;
132135
}
136+
137+
function fetchWithTimeout(input: string, init: RequestInit = {}) {
138+
const controller = new AbortController();
139+
const timeoutId = setTimeout(() => controller.abort(), NEAR_RPC_TIMEOUT_MS);
140+
141+
return fetch(input, { ...init, signal: controller.signal }).finally(() => {
142+
clearTimeout(timeoutId);
143+
});
144+
}

src/app/pingpay/callback/page.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,19 +111,21 @@ async function pollSessionStatus(sessionId: string): Promise<{
111111
txHash: string | null;
112112
senderId: string | null;
113113
} | null> {
114-
// 60 attempts × 2 s = 120 s. PingPay's relayer can take >30 s to submit the
115-
// on-chain tx, especially for first-ever donations to a recipient. The old
116-
// 30 s window silently dropped the sync when settlement was slow.
117-
const maxAttempts = 60;
118-
const delayMs = 2000;
114+
// Keep a long settlement window, but back off so each checkout does not keep
115+
// a Vercel function hot with dozens of identical status calls.
116+
const delaysMs = [
117+
...Array.from({ length: 5 }, () => 2000),
118+
...Array.from({ length: 8 }, () => 5000),
119+
...Array.from({ length: 8 }, () => 10000),
120+
];
119121

120122
let lastData: {
121123
status: string;
122124
txHash: string | null;
123125
senderId: string | null;
124126
} | null = null;
125127

126-
for (let i = 0; i < maxAttempts; i++) {
128+
for (let i = 0; i <= delaysMs.length; i++) {
127129
try {
128130
const res = await fetch(
129131
`/api/pingpay/session-status?sessionId=${encodeURIComponent(sessionId)}`,
@@ -143,7 +145,8 @@ async function pollSessionStatus(sessionId: string): Promise<{
143145
// retry
144146
}
145147

146-
await new Promise((r) => setTimeout(r, delayMs));
148+
const delayMs = delaysMs[i];
149+
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
147150
}
148151

149152
return lastData;

src/common/blockchains/near-protocol/client.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,87 @@ import type {
77
QueryResponseKind,
88
} from "@near-js/types/lib/provider/response";
99
import { providers } from "near-api-js";
10+
import type { CodeResult } from "near-api-js/lib/providers/provider";
1011

1112
import { NETWORK, SOCIAL_DB_CONTRACT_ACCOUNT_ID } from "@/common/_config";
1213
import { FULL_TGAS } from "@/common/constants";
1314

14-
export const RPC_NODE_URL = `https://${NETWORK === "mainnet" ? "free.rpc.fastnear.com" : "test.rpc.fastnear.com"}`;
15+
const RPC_NODE_URLS =
16+
NETWORK === "mainnet"
17+
? [
18+
"https://1rpc.io/near",
19+
"https://free.rpc.fastnear.com",
20+
"https://near.blockpi.network/v1/rpc/public",
21+
"https://near.lava.build",
22+
"https://rpc.ankr.com/near",
23+
]
24+
: ["https://rpc.testnet.near.org", "https://test.rpc.fastnear.com"];
1525

16-
const nearRpc = new providers.JsonRpcProvider({ url: RPC_NODE_URL });
26+
export const RPC_NODE_URL = RPC_NODE_URLS[0];
27+
28+
const RPC_COOLDOWN_MS = 60_000;
29+
30+
const rpcCooldownUntil = new Map<string, number>();
31+
32+
const rpcProviders = RPC_NODE_URLS.map((url) => ({
33+
url,
34+
provider: new providers.JsonRpcProvider({ url }),
35+
}));
36+
37+
let preferredRpcIndex = 0;
38+
39+
const getRpcProviderOrder = () => {
40+
const indexes = rpcProviders.map((_, index) => index);
41+
42+
return [preferredRpcIndex, ...indexes.filter((index) => index !== preferredRpcIndex)];
43+
};
44+
45+
const markRpcUnavailable = (url: string) => {
46+
rpcCooldownUntil.set(url, Date.now() + RPC_COOLDOWN_MS);
47+
};
48+
49+
const isRpcAvailable = (url: string) => (rpcCooldownUntil.get(url) ?? 0) <= Date.now();
50+
51+
type NearRpcProvider = InstanceType<typeof providers.JsonRpcProvider>;
52+
53+
const queryNearRpc = async <R>(run: (provider: NearRpcProvider) => Promise<R>): Promise<R> => {
54+
let lastError: unknown;
55+
56+
for (const index of getRpcProviderOrder()) {
57+
const { provider, url } = rpcProviders[index];
58+
59+
if (!isRpcAvailable(url)) continue;
60+
61+
try {
62+
const result = await run(provider);
63+
preferredRpcIndex = index;
64+
return result;
65+
} catch (error) {
66+
lastError = error;
67+
markRpcUnavailable(url);
68+
}
69+
}
70+
71+
const fallbackIndex = preferredRpcIndex === 0 ? 1 : 0;
72+
const fallback = rpcProviders[fallbackIndex] ?? rpcProviders[0];
73+
74+
try {
75+
const result = await run(fallback.provider);
76+
preferredRpcIndex = fallbackIndex;
77+
return result;
78+
} catch (error) {
79+
throw lastError ?? error;
80+
}
81+
};
82+
83+
const nearRpc = {
84+
query: <R extends QueryResponseKind = CodeResult>(
85+
...args: Parameters<NearRpcProvider["query"]>
86+
) => queryNearRpc((provider) => provider.query<R>(...args)),
87+
88+
txStatus: (...args: Parameters<NearRpcProvider["txStatus"]>) =>
89+
queryNearRpc((provider) => provider.txStatus(...args)),
90+
};
1791

1892
type CallProps<A extends object = Record<string, unknown>> = {
1993
args?: A;

src/middleware.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,7 @@ export async function middleware(request: NextRequest) {
4040

4141
return NextResponse.next();
4242
}
43+
44+
export const config = {
45+
matcher: ["/profile/:path*"],
46+
};

src/pages/campaign/[campaignId]/index.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { ReactElement, useMemo } from "react";
33
import type { GetStaticPaths, GetStaticProps } from "next";
44
import { useRouter } from "next/router";
55

6-
import { INDEXER_API_ENDPOINT_URL } from "@/common/_config";
76
import { APP_METADATA } from "@/common/constants";
87
import { CampaignDonorsTable, CampaignSettings } from "@/entities/campaign";
98
import { CampaignLayout } from "@/layout/campaign/components/layout";
@@ -120,9 +119,9 @@ export const getStaticProps: GetStaticProps<CampaignPageProps> = async (context)
120119
image: campaign?.cover_image_url ?? fallbackSeo.image,
121120
};
122121

123-
return { props: { seo }, revalidate: 300 };
122+
return { props: { seo }, revalidate: 3600 };
124123
} catch {
125-
return { props: { seo: fallbackSeo }, revalidate: 60 };
124+
return { props: { seo: fallbackSeo }, revalidate: 600 };
126125
} finally {
127126
clearTimeout(timeoutId);
128127
}

src/pages/robots.txt.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ ${sitemapLines}
2626
`;
2727

2828
res.setHeader("Content-Type", "text/plain; charset=utf-8");
29-
res.setHeader("Cache-Control", "s-maxage=1800, stale-while-revalidate=3600");
29+
res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=86400");
3030
res.write(robots);
3131
res.end();
3232

0 commit comments

Comments
 (0)