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
36 changes: 20 additions & 16 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
##* Deployment environment
PINATA_JWT=
NEXT_PUBLIC_IPFS_GATEWAY_URL=potlock.mypinata.cloud

# test | staging | production
NEXT_PUBLIC_ENV=test

##* Debugging and testing only
##! WARNING: DO NOT USE THESE VARIABLES IN PRODUCTION DEPLOYMENTS, OTHERWISE IT WILL BREAK UX

# true | false
NEXT_PUBLIC_DEBUG=false

# An account id of the account you want to impersonate for testing purposes
# Useful for permission control testing
NEXT_PUBLIC_DEBUG_VIEW_AS=
##* Deployment environment
PINATA_JWT=
NEXT_PUBLIC_IPFS_GATEWAY_URL=potlock.mypinata.cloud

##* PingPay Hosted Checkout (server-side only — never expose to client)
## Get a key at https://pay.pingpay.io/dashboard
PINGPAY_API_KEY=

# test | staging | production
NEXT_PUBLIC_ENV=test

##* Debugging and testing only
##! WARNING: DO NOT USE THESE VARIABLES IN PRODUCTION DEPLOYMENTS, OTHERWISE IT WILL BREAK UX

# true | false
NEXT_PUBLIC_DEBUG=false

# An account id of the account you want to impersonate for testing purposes
# Useful for permission control testing
NEXT_PUBLIC_DEBUG_VIEW_AS=
100 changes: 100 additions & 0 deletions src/app/api/pingpay/create-checkout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { NextResponse } from "next/server";

import { CAMPAIGNS_CONTRACT_ACCOUNT_ID } from "@/common/_config";

export const dynamic = "force-dynamic";

const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api";

// TODO: move to env var and rotate before going public.
const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk";

/**
* Creates a PingPay Hosted Checkout session that settles to the POTLOCK
* campaigns contract. Routing to a specific campaign is done via
* `customRecipientMsg`, which the contract's `ft_on_transfer` decodes.
*
* Setup requirement: in the PingPay dashboard, the NEAR recipient address
* must be set to {@link CAMPAIGNS_CONTRACT_ACCOUNT_ID}. PingPay's API ignores
* any per-request recipient and always settles to the dashboard-configured one.
*
* @link https://pingpay.gitbook.io/docs/pingpay-api/hosted-checkout/create-checkout
*/
export async function POST(req: Request) {
try {
const apiKey = process.env.PINGPAY_API_KEY ?? PINGPAY_API_KEY_FALLBACK;

const body = await req.json();

const {
amount,
asset,
campaignId,
referrerAccountId,
donorAccountId,
donorMessage,
successUrl,
cancelUrl,
} = body ?? {};

if (!amount || campaignId === undefined || campaignId === null) {
return NextResponse.json(
{ error: "Missing required fields: amount, campaignId" },
{ status: 400 },
);
}

const donorTag = donorAccountId ? `[PingPay donor: ${donorAccountId}]` : "[via PingPay]";
const taggedMessage = donorMessage ? `${donorTag} ${donorMessage}` : donorTag;

const customRecipientMsg = JSON.stringify({
campaign_id: typeof campaignId === "string" ? Number(campaignId) : campaignId,
referrer_id: referrerAccountId ?? null,
bypass_protocol_fee: false,
bypass_creator_fee: false,
message: taggedMessage,
});

const response = await fetch(`${PINGPAY_API_BASE}/checkout/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
body: JSON.stringify({
amount,
asset,
recipient: CAMPAIGNS_CONTRACT_ACCOUNT_ID,
customRecipientMsg,
successUrl,
cancelUrl,
metadata: {
source: "potlock",
campaignId: campaignId.toString(),
donorAccountId: donorAccountId ?? null,
},
}),
});

const text = await response.text();
let data: any;

try {
data = text ? JSON.parse(text) : {};
} catch {
data = { error: text || "Non-JSON response from PingPay" };
}

if (!response.ok) {
return NextResponse.json(
{ error: data?.message ?? data?.error ?? "Failed to create PingPay checkout" },
{ status: response.status },
);
}

return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("PingPay checkout error:", error);
return NextResponse.json({ error: "Failed to create checkout" }, { status: 500 });
}
}
101 changes: 101 additions & 0 deletions src/app/api/pingpay/create-project-checkout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { NextResponse } from "next/server";

import { DONATION_CONTRACT_ACCOUNT_ID } from "@/common/_config";

export const dynamic = "force-dynamic";

const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api";

// TODO: move to env var and rotate before going public.
const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk";

/**
* Creates a PingPay Hosted Checkout session that settles to the POTLOCK
* donation contract for a direct project donation. Routing to the recipient
* account is done via `customRecipientMsg`, which the contract's
* `ft_on_transfer` decodes.
*
* Setup requirement: in the PingPay dashboard, the NEAR recipient address
* must be set to {@link DONATION_CONTRACT_ACCOUNT_ID}. PingPay's API ignores
* any per-request recipient and always settles to the dashboard-configured one.
*/
export async function POST(req: Request) {
try {
const apiKey = process.env.PINGPAY_API_KEY ?? PINGPAY_API_KEY_FALLBACK;

const body = await req.json();

const {
amount,
asset,
recipientAccountId,
referrerAccountId,
donorAccountId,
donorMessage,
successUrl,
cancelUrl,
} = body ?? {};

if (!amount || !recipientAccountId) {
return NextResponse.json(
{ error: "Missing required fields: amount, recipientAccountId" },
{ status: 400 },
);
}

// Embed donor account in the on-chain message so the real donor is
// recoverable — sender_id on the FT transfer is intent.near (PingPay's
// settlement gateway), not the human donor.
const donorTag = donorAccountId ? `[PingPay donor: ${donorAccountId}]` : "[via PingPay]";
const taggedMessage = donorMessage ? `${donorTag} ${donorMessage}` : donorTag;

const customRecipientMsg = JSON.stringify({
recipient_id: recipientAccountId,
referrer_id: referrerAccountId ?? null,
bypass_protocol_fee: false,
message: taggedMessage,
});

const response = await fetch(`${PINGPAY_API_BASE}/checkout/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
body: JSON.stringify({
amount,
asset,
recipient: DONATION_CONTRACT_ACCOUNT_ID,
customRecipientMsg,
successUrl,
cancelUrl,
metadata: {
source: "potlock",
recipientAccountId,
donorAccountId: donorAccountId ?? null,
},
}),
});

const text = await response.text();
let data: any;

try {
data = text ? JSON.parse(text) : {};
} catch {
data = { error: text || "Non-JSON response from PingPay" };
}

if (!response.ok) {
return NextResponse.json(
{ error: data?.message ?? data?.error ?? "Failed to create PingPay checkout" },
{ status: response.status },
);
}

return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("PingPay project checkout error:", error);
return NextResponse.json({ error: "Failed to create checkout" }, { status: 500 });
}
}
74 changes: 74 additions & 0 deletions src/app/api/pingpay/session-status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";

export const dynamic = "force-dynamic";

const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api";

// TODO: move to env var and rotate before going public.
const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk";

/**
* Fetches PingPay session + payment details to extract txHash and sender.
* Used by the callback page to fire the correct sync call.
*/
export async function GET(req: Request) {
try {
const apiKey = process.env.PINGPAY_API_KEY ?? PINGPAY_API_KEY_FALLBACK;

const { searchParams } = new URL(req.url);
const sessionId = searchParams.get("sessionId");

if (!sessionId) {
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 },
});

if (!sessionRes.ok) {
return NextResponse.json({ error: "Failed to fetch session" }, { status: sessionRes.status });
}

const sessionData = await sessionRes.json();
const paymentId = sessionData?.session?.paymentId;

if (!paymentId) {
return NextResponse.json({
status: sessionData?.session?.status ?? "UNKNOWN",
txHash: null,
senderId: null,
});
}

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

if (!paymentRes.ok) {
return NextResponse.json({
status: sessionData?.session?.status ?? "UNKNOWN",
txHash: null,
senderId: null,
});
}

const paymentData = await paymentRes.json();
const payment = paymentData?.payment;

return NextResponse.json({
status: payment?.status ?? sessionData?.session?.status ?? "UNKNOWN",
txHash: payment?.txHash ?? null,
senderId: payment?.request?.payer?.address ?? null,
recipient: payment?.request?.recipient?.address ?? null,
amount: payment?.request?.amount ?? null,
assetSymbol: payment?.request?.asset?.symbol ?? null,
sessionMetadata: sessionData?.session?.metadata ?? null,
});
} catch (error) {
console.error("PingPay session-status error:", error);
return NextResponse.json({ error: "Failed to fetch session status" }, { status: 500 });
}
}
Loading
Loading