diff --git a/.env.example b/.env.example index 4189d9a7..a3d0c9e7 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/src/app/api/pingpay/create-checkout/route.ts b/src/app/api/pingpay/create-checkout/route.ts new file mode 100644 index 00000000..3acabe88 --- /dev/null +++ b/src/app/api/pingpay/create-checkout/route.ts @@ -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 }); + } +} diff --git a/src/app/api/pingpay/create-project-checkout/route.ts b/src/app/api/pingpay/create-project-checkout/route.ts new file mode 100644 index 00000000..f6ffad55 --- /dev/null +++ b/src/app/api/pingpay/create-project-checkout/route.ts @@ -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 }); + } +} diff --git a/src/app/api/pingpay/session-status/route.ts b/src/app/api/pingpay/session-status/route.ts new file mode 100644 index 00000000..6350dab6 --- /dev/null +++ b/src/app/api/pingpay/session-status/route.ts @@ -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 }); + } +} diff --git a/src/app/api/pingpay/verify-tx/route.ts b/src/app/api/pingpay/verify-tx/route.ts new file mode 100644 index 00000000..b0ce0743 --- /dev/null +++ b/src/app/api/pingpay/verify-tx/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server"; + +import { DONATION_CONTRACT_ACCOUNT_ID, NETWORK } from "@/common/_config"; + +export const dynamic = "force-dynamic"; + +const NEAR_RPC_URL = + NETWORK === "mainnet" ? "https://rpc.mainnet.near.org" : "https://rpc.testnet.near.org"; + +/** + * Confirms a PingPay-sourced donation actually landed on-chain (not refunded). + * + * Why: PingPay settles through intent.near. The deployed donation contract's + * ft_on_transfer can refund when it can't attribute the donor. Before we call + * the sync API (which makes the donation visible in history), we verify the + * transaction: + * 1. Succeeded. + * 2. Produced a `donation` log event (standard: potlock) on the donation + * contract for the expected recipient + amount. + * + * If the log is missing, the donation was refunded — we must not sync it. + */ +export async function POST(req: Request) { + try { + const body = await req.json(); + const { txHash, senderId, expectedRecipient, expectedAmount } = body ?? {}; + + if (!txHash || !senderId) { + return NextResponse.json({ error: "Missing txHash or senderId" }, { status: 400 }); + } + + const rpcRes = await fetch(NEAR_RPC_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "verify", + method: "EXPERIMENTAL_tx_status", + params: [txHash, senderId, "EXECUTED_OPTIMISTIC"], + }), + }); + + if (!rpcRes.ok) { + return NextResponse.json({ error: `RPC returned ${rpcRes.status}` }, { status: 502 }); + } + + const rpcData = await rpcRes.json(); + + if (rpcData?.error) { + return NextResponse.json( + { landed: false, reason: "rpc_error", detail: rpcData.error }, + { status: 200 }, + ); + } + + const receipts: any[] = rpcData?.result?.receipts_outcome ?? []; + const donationLog = findDonationLog(receipts, expectedRecipient); + + if (!donationLog) { + return NextResponse.json( + { + landed: false, + reason: "no_donation_log", + note: "Donation contract did not emit a donation event for the expected recipient — likely refunded.", + }, + { status: 200 }, + ); + } + + if (expectedAmount && donationLog.net_amount && donationLog.total_amount) { + // Basic sanity: total should be >= expected (we sent amount, fees deducted net) + const total = BigInt(donationLog.total_amount); + const expected = BigInt(expectedAmount); + + if (total < expected / 2n) { + return NextResponse.json( + { + landed: false, + reason: "amount_mismatch", + onChainTotal: donationLog.total_amount, + expected: expectedAmount, + }, + { status: 200 }, + ); + } + } + + return NextResponse.json({ + landed: true, + donation: donationLog, + }); + } catch (error) { + console.error("PingPay verify-tx error:", error); + return NextResponse.json({ error: "Failed to verify tx" }, { status: 500 }); + } +} + +/** + * Walks receipts_outcome looking for a `donation` event emitted by the donation + * contract for the expected recipient. The contract emits + * `EVENT_JSON:{"standard":"potlock","event":"donation","data":[{"donation":{...}}]}` + * after `ft_on_transfer` successfully forwards funds. If no matching event is + * found the donation was refunded. + */ +function findDonationLog(receipts: any[], expectedRecipient?: string): any | null { + for (const receipt of receipts) { + if (receipt?.outcome?.executor_id !== DONATION_CONTRACT_ACCOUNT_ID) continue; + + const logs: string[] = receipt?.outcome?.logs ?? []; + + for (const log of logs) { + if (!log.startsWith("EVENT_JSON:")) continue; + + try { + const event = JSON.parse(log.slice("EVENT_JSON:".length)); + if (event?.event !== "donation") continue; + + const data = Array.isArray(event?.data) ? event.data[0] : event?.data; + const donation = data?.donation ?? data; + if (!donation) continue; + + if (expectedRecipient && donation.recipient_id !== expectedRecipient) continue; + + return donation; + } catch { + // skip malformed event + } + } + } + + return null; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 00000000..c9194cf7 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,12 @@ +export const metadata = { + title: "Next.js", + description: "Generated by Next.js", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + +
{children} + + ); +} diff --git a/src/app/pingpay/callback/page.tsx b/src/app/pingpay/callback/page.tsx new file mode 100644 index 00000000..2dc9d30c --- /dev/null +++ b/src/app/pingpay/callback/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; + +import { Loader2 } from "lucide-react"; +import { useSearchParams } from "next/navigation"; + +import { syncApi } from "@/common/api/indexer/sync"; + +const Spinner = () => ( ++ {"Donation processed. You can close this tab."} +
+ + {"Back to Potlock"} + +
Refunded
diff --git a/src/features/pingpay/components/FastDonateButton.tsx b/src/features/pingpay/components/FastDonateButton.tsx
new file mode 100644
index 00000000..40d46c2d
--- /dev/null
+++ b/src/features/pingpay/components/FastDonateButton.tsx
@@ -0,0 +1,50 @@
+import { useCallback } from "react";
+
+import { show } from "@ebay/nice-modal-react";
+
+import { Button } from "@/common/ui/layout/components";
+import { cn } from "@/common/ui/layout/utils";
+
+import { PingPayModal } from "./PingPayModal";
+
+export type FastDonateButtonProps = {
+ recipientAccountId: string;
+ tokenId: string;
+ campaignId?: string | number;
+ campaignName?: string;
+ className?: string;
+ disabled?: boolean;
+};
+
+export const FastDonateButton: React.FC