Skip to content

Commit 427fa2b

Browse files
authored
Merge pull request #623 from PotLock/feature/pingpay-phase-1
Feature/pingpay phase 1
2 parents 0101ffe + 209c068 commit 427fa2b

17 files changed

Lines changed: 1051 additions & 17 deletions

File tree

.env.example

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1-
##* Deployment environment
2-
PINATA_JWT=
3-
NEXT_PUBLIC_IPFS_GATEWAY_URL=potlock.mypinata.cloud
4-
5-
# test | staging | production
6-
NEXT_PUBLIC_ENV=test
7-
8-
##* Debugging and testing only
9-
##! WARNING: DO NOT USE THESE VARIABLES IN PRODUCTION DEPLOYMENTS, OTHERWISE IT WILL BREAK UX
10-
11-
# true | false
12-
NEXT_PUBLIC_DEBUG=false
13-
14-
# An account id of the account you want to impersonate for testing purposes
15-
# Useful for permission control testing
16-
NEXT_PUBLIC_DEBUG_VIEW_AS=
1+
##* Deployment environment
2+
PINATA_JWT=
3+
NEXT_PUBLIC_IPFS_GATEWAY_URL=potlock.mypinata.cloud
4+
5+
##* PingPay Hosted Checkout (server-side only — never expose to client)
6+
## Get a key at https://pay.pingpay.io/dashboard
7+
PINGPAY_API_KEY=
8+
9+
# test | staging | production
10+
NEXT_PUBLIC_ENV=test
11+
12+
##* Debugging and testing only
13+
##! WARNING: DO NOT USE THESE VARIABLES IN PRODUCTION DEPLOYMENTS, OTHERWISE IT WILL BREAK UX
14+
15+
# true | false
16+
NEXT_PUBLIC_DEBUG=false
17+
18+
# An account id of the account you want to impersonate for testing purposes
19+
# Useful for permission control testing
20+
NEXT_PUBLIC_DEBUG_VIEW_AS=
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { NextResponse } from "next/server";
2+
3+
import { CAMPAIGNS_CONTRACT_ACCOUNT_ID } from "@/common/_config";
4+
5+
export const dynamic = "force-dynamic";
6+
7+
const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api";
8+
9+
// TODO: move to env var and rotate before going public.
10+
const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk";
11+
12+
/**
13+
* Creates a PingPay Hosted Checkout session that settles to the POTLOCK
14+
* campaigns contract. Routing to a specific campaign is done via
15+
* `customRecipientMsg`, which the contract's `ft_on_transfer` decodes.
16+
*
17+
* Setup requirement: in the PingPay dashboard, the NEAR recipient address
18+
* must be set to {@link CAMPAIGNS_CONTRACT_ACCOUNT_ID}. PingPay's API ignores
19+
* any per-request recipient and always settles to the dashboard-configured one.
20+
*
21+
* @link https://pingpay.gitbook.io/docs/pingpay-api/hosted-checkout/create-checkout
22+
*/
23+
export async function POST(req: Request) {
24+
try {
25+
const apiKey = process.env.PINGPAY_API_KEY ?? PINGPAY_API_KEY_FALLBACK;
26+
27+
const body = await req.json();
28+
29+
const {
30+
amount,
31+
asset,
32+
campaignId,
33+
referrerAccountId,
34+
donorAccountId,
35+
donorMessage,
36+
successUrl,
37+
cancelUrl,
38+
} = body ?? {};
39+
40+
if (!amount || campaignId === undefined || campaignId === null) {
41+
return NextResponse.json(
42+
{ error: "Missing required fields: amount, campaignId" },
43+
{ status: 400 },
44+
);
45+
}
46+
47+
const donorTag = donorAccountId ? `[PingPay donor: ${donorAccountId}]` : "[via PingPay]";
48+
const taggedMessage = donorMessage ? `${donorTag} ${donorMessage}` : donorTag;
49+
50+
const customRecipientMsg = JSON.stringify({
51+
campaign_id: typeof campaignId === "string" ? Number(campaignId) : campaignId,
52+
referrer_id: referrerAccountId ?? null,
53+
bypass_protocol_fee: false,
54+
bypass_creator_fee: false,
55+
message: taggedMessage,
56+
});
57+
58+
const response = await fetch(`${PINGPAY_API_BASE}/checkout/sessions`, {
59+
method: "POST",
60+
headers: {
61+
"Content-Type": "application/json",
62+
"x-api-key": apiKey,
63+
},
64+
body: JSON.stringify({
65+
amount,
66+
asset,
67+
recipient: CAMPAIGNS_CONTRACT_ACCOUNT_ID,
68+
customRecipientMsg,
69+
successUrl,
70+
cancelUrl,
71+
metadata: {
72+
source: "potlock",
73+
campaignId: campaignId.toString(),
74+
donorAccountId: donorAccountId ?? null,
75+
},
76+
}),
77+
});
78+
79+
const text = await response.text();
80+
let data: any;
81+
82+
try {
83+
data = text ? JSON.parse(text) : {};
84+
} catch {
85+
data = { error: text || "Non-JSON response from PingPay" };
86+
}
87+
88+
if (!response.ok) {
89+
return NextResponse.json(
90+
{ error: data?.message ?? data?.error ?? "Failed to create PingPay checkout" },
91+
{ status: response.status },
92+
);
93+
}
94+
95+
return NextResponse.json(data, { status: 200 });
96+
} catch (error) {
97+
console.error("PingPay checkout error:", error);
98+
return NextResponse.json({ error: "Failed to create checkout" }, { status: 500 });
99+
}
100+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { NextResponse } from "next/server";
2+
3+
import { DONATION_CONTRACT_ACCOUNT_ID } from "@/common/_config";
4+
5+
export const dynamic = "force-dynamic";
6+
7+
const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api";
8+
9+
// TODO: move to env var and rotate before going public.
10+
const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk";
11+
12+
/**
13+
* Creates a PingPay Hosted Checkout session that settles to the POTLOCK
14+
* donation contract for a direct project donation. Routing to the recipient
15+
* account is done via `customRecipientMsg`, which the contract's
16+
* `ft_on_transfer` decodes.
17+
*
18+
* Setup requirement: in the PingPay dashboard, the NEAR recipient address
19+
* must be set to {@link DONATION_CONTRACT_ACCOUNT_ID}. PingPay's API ignores
20+
* any per-request recipient and always settles to the dashboard-configured one.
21+
*/
22+
export async function POST(req: Request) {
23+
try {
24+
const apiKey = process.env.PINGPAY_API_KEY ?? PINGPAY_API_KEY_FALLBACK;
25+
26+
const body = await req.json();
27+
28+
const {
29+
amount,
30+
asset,
31+
recipientAccountId,
32+
referrerAccountId,
33+
donorAccountId,
34+
donorMessage,
35+
successUrl,
36+
cancelUrl,
37+
} = body ?? {};
38+
39+
if (!amount || !recipientAccountId) {
40+
return NextResponse.json(
41+
{ error: "Missing required fields: amount, recipientAccountId" },
42+
{ status: 400 },
43+
);
44+
}
45+
46+
// Embed donor account in the on-chain message so the real donor is
47+
// recoverable — sender_id on the FT transfer is intent.near (PingPay's
48+
// settlement gateway), not the human donor.
49+
const donorTag = donorAccountId ? `[PingPay donor: ${donorAccountId}]` : "[via PingPay]";
50+
const taggedMessage = donorMessage ? `${donorTag} ${donorMessage}` : donorTag;
51+
52+
const customRecipientMsg = JSON.stringify({
53+
recipient_id: recipientAccountId,
54+
referrer_id: referrerAccountId ?? null,
55+
bypass_protocol_fee: false,
56+
message: taggedMessage,
57+
});
58+
59+
const response = await fetch(`${PINGPAY_API_BASE}/checkout/sessions`, {
60+
method: "POST",
61+
headers: {
62+
"Content-Type": "application/json",
63+
"x-api-key": apiKey,
64+
},
65+
body: JSON.stringify({
66+
amount,
67+
asset,
68+
recipient: DONATION_CONTRACT_ACCOUNT_ID,
69+
customRecipientMsg,
70+
successUrl,
71+
cancelUrl,
72+
metadata: {
73+
source: "potlock",
74+
recipientAccountId,
75+
donorAccountId: donorAccountId ?? null,
76+
},
77+
}),
78+
});
79+
80+
const text = await response.text();
81+
let data: any;
82+
83+
try {
84+
data = text ? JSON.parse(text) : {};
85+
} catch {
86+
data = { error: text || "Non-JSON response from PingPay" };
87+
}
88+
89+
if (!response.ok) {
90+
return NextResponse.json(
91+
{ error: data?.message ?? data?.error ?? "Failed to create PingPay checkout" },
92+
{ status: response.status },
93+
);
94+
}
95+
96+
return NextResponse.json(data, { status: 200 });
97+
} catch (error) {
98+
console.error("PingPay project checkout error:", error);
99+
return NextResponse.json({ error: "Failed to create checkout" }, { status: 500 });
100+
}
101+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { NextResponse } from "next/server";
2+
3+
export const dynamic = "force-dynamic";
4+
5+
const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api";
6+
7+
// TODO: move to env var and rotate before going public.
8+
const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk";
9+
10+
/**
11+
* Fetches PingPay session + payment details to extract txHash and sender.
12+
* Used by the callback page to fire the correct sync call.
13+
*/
14+
export async function GET(req: Request) {
15+
try {
16+
const apiKey = process.env.PINGPAY_API_KEY ?? PINGPAY_API_KEY_FALLBACK;
17+
18+
const { searchParams } = new URL(req.url);
19+
const sessionId = searchParams.get("sessionId");
20+
21+
if (!sessionId) {
22+
return NextResponse.json({ error: "Missing sessionId" }, { status: 400 });
23+
}
24+
25+
// Fetch session to get paymentId
26+
const sessionRes = await fetch(`${PINGPAY_API_BASE}/checkout/sessions/${sessionId}`, {
27+
headers: { "x-api-key": apiKey },
28+
});
29+
30+
if (!sessionRes.ok) {
31+
return NextResponse.json({ error: "Failed to fetch session" }, { status: sessionRes.status });
32+
}
33+
34+
const sessionData = await sessionRes.json();
35+
const paymentId = sessionData?.session?.paymentId;
36+
37+
if (!paymentId) {
38+
return NextResponse.json({
39+
status: sessionData?.session?.status ?? "UNKNOWN",
40+
txHash: null,
41+
senderId: null,
42+
});
43+
}
44+
45+
// Fetch payment to get txHash and sender
46+
const paymentRes = await fetch(`${PINGPAY_API_BASE}/payments/${paymentId}`, {
47+
headers: { "x-api-key": apiKey },
48+
});
49+
50+
if (!paymentRes.ok) {
51+
return NextResponse.json({
52+
status: sessionData?.session?.status ?? "UNKNOWN",
53+
txHash: null,
54+
senderId: null,
55+
});
56+
}
57+
58+
const paymentData = await paymentRes.json();
59+
const payment = paymentData?.payment;
60+
61+
return NextResponse.json({
62+
status: payment?.status ?? sessionData?.session?.status ?? "UNKNOWN",
63+
txHash: payment?.txHash ?? null,
64+
senderId: payment?.request?.payer?.address ?? null,
65+
recipient: payment?.request?.recipient?.address ?? null,
66+
amount: payment?.request?.amount ?? null,
67+
assetSymbol: payment?.request?.asset?.symbol ?? null,
68+
sessionMetadata: sessionData?.session?.metadata ?? null,
69+
});
70+
} catch (error) {
71+
console.error("PingPay session-status error:", error);
72+
return NextResponse.json({ error: "Failed to fetch session status" }, { status: 500 });
73+
}
74+
}

0 commit comments

Comments
 (0)