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..f2dc368d
--- /dev/null
+++ b/src/app/pingpay/callback/page.tsx
@@ -0,0 +1,150 @@
+"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 = () => (
+
+
+
+);
+
+function PingPayCallbackInner() {
+ const searchParams = useSearchParams();
+ const status = searchParams?.get("status") ?? null;
+ const type = searchParams?.get("type") ?? null;
+ const id = searchParams?.get("id") ?? null;
+
+ const [showFallback, setShowFallback] = useState(false);
+
+ const returnUrl =
+ type === "campaign" ? `/campaign/${id}` : type === "project" ? `/profile/${id}` : "/";
+
+ useEffect(() => {
+ const run = async () => {
+ if (status === "success" && id) {
+ try {
+ let sessionId: string | null = null;
+
+ try {
+ sessionId = localStorage.getItem("pingpay_session_id");
+ localStorage.removeItem("pingpay_session_id");
+ localStorage.removeItem("pingpay_donor_account_id");
+ } catch {
+ // localStorage unavailable
+ }
+
+ if (sessionId) {
+ const data = await pollSessionStatus(sessionId);
+ const txHash = data?.txHash ?? null;
+
+ // PingPay routes donations through NEAR Intents — the on-chain tx
+ // signer is always `intents.near`, regardless of which user wallet
+ // PingPay reports as the payer. The sync endpoint passes this to
+ // NEAR RPC's `tx` method, which requires the actual signer.
+ const senderId = "intents.near";
+
+ if (txHash) {
+ if (type === "campaign") {
+ await syncApi.campaignDonation(id, txHash, senderId);
+ } else if (type === "project") {
+ await syncApi.directDonation(txHash, senderId);
+ // Recalculate the recipient's aggregate stats (total_donations_in_usd,
+ // donors_count) — the donation sync only inserts the row.
+ await syncApi.account(id);
+ }
+ }
+ }
+ } catch (e) {
+ console.error("PingPay sync error:", e);
+ }
+ }
+
+ try {
+ const channel = new BroadcastChannel("pingpay");
+ channel.postMessage({ type: "pingpay-complete", paymentStatus: status, kind: type, id });
+ channel.close();
+ } catch {
+ // BroadcastChannel unsupported
+ }
+
+ window.close();
+ setTimeout(() => setShowFallback(true), 800);
+ };
+
+ run();
+ }, [status, type, id]);
+
+ if (!showFallback) return ;
+
+ return (
+
+ );
+}
+
+export default function PingPayCallbackPage() {
+ return (
+ }>
+
+
+ );
+}
+
+async function pollSessionStatus(sessionId: string): Promise<{
+ status: string;
+ 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;
+
+ let lastData: {
+ status: string;
+ txHash: string | null;
+ senderId: string | null;
+ } | null = null;
+
+ for (let i = 0; i < maxAttempts; i++) {
+ try {
+ const res = await fetch(
+ `/api/pingpay/session-status?sessionId=${encodeURIComponent(sessionId)}`,
+ );
+
+ if (res.ok) {
+ const data = await res.json();
+ lastData = data;
+ const s = (data.status ?? "").toUpperCase();
+ const isFail = s === "FAILED" || s === "CANCELLED" || s === "EXPIRED";
+ const isSuccess = s === "COMPLETED" || s === "SUCCESS" || s === "SUCCEEDED";
+
+ if (isFail) return data;
+ if (isSuccess && data.txHash) return data;
+ }
+ } catch {
+ // retry
+ }
+
+ await new Promise((r) => setTimeout(r, delayMs));
+ }
+
+ return lastData;
+}
diff --git a/src/entities/campaign/components/CampaignBanner.tsx b/src/entities/campaign/components/CampaignBanner.tsx
index d8cefd56..18439fdb 100644
--- a/src/entities/campaign/components/CampaignBanner.tsx
+++ b/src/entities/campaign/components/CampaignBanner.tsx
@@ -20,6 +20,7 @@ import { useWalletUserSession } from "@/common/wallet";
import { AccountProfileLink } from "@/entities/_shared/account";
import { useFungibleToken } from "@/entities/_shared/token";
import { DonateToCampaign } from "@/features/donation";
+// import { FastDonateButton } from "@/features/pingpay";
import { CampaignProgressBar } from "./CampaignProgressBar";
import { useCampaignForm } from "../hooks/forms";
@@ -273,6 +274,16 @@ export const CampaignBanner: React.FC = ({ campaignId }) =>
{...{ campaignId }}
/>
+ {/* Create Payment link disabled — campaigns contract for pinpay is not active, needs migration for pingpay integration. */}
+ {/* */}
+
= ({ campai
cell: ({ row }) => {
const donorId = row.original.donor_id;
const isIntent = donorId.includes("potluck_intents.near");
+ const isPingPay = (row.original.message ?? "").startsWith("[via PingPay]");
const explorerUrl = isIntent
? `https://nearblocks.io/address/${donorId}`
@@ -49,6 +50,12 @@ export const CampaignDonorsTable: React.FC = ({ campai
)}
+ {isPingPay && (
+
+ via PingPay
+
+ )}
+
{row.original?.returned_at_ms && (
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 = ({
+ campaignId,
+ campaignName,
+ className,
+ disabled,
+}) => {
+ const handleClick = useCallback(() => {
+ if (campaignId === undefined) return;
+
+ show(PingPayModal, {
+ tokenSymbol: "USDC",
+ tokenDecimals: 6,
+ campaignId,
+ campaignName,
+ });
+ }, [campaignId, campaignName]);
+
+ return (
+
+ );
+};
diff --git a/src/features/pingpay/components/FastDonateToProjectButton.tsx b/src/features/pingpay/components/FastDonateToProjectButton.tsx
new file mode 100644
index 00000000..9962c9d7
--- /dev/null
+++ b/src/features/pingpay/components/FastDonateToProjectButton.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 FastDonateToProjectButtonProps = {
+ recipientAccountId: string;
+ recipientName?: string;
+ className?: string;
+ disabled?: boolean;
+};
+
+export const FastDonateToProjectButton: React.FC = ({
+ recipientAccountId,
+ recipientName,
+ className,
+ disabled,
+}) => {
+ const handleClick = useCallback(() => {
+ if (!recipientAccountId) return;
+
+ show(PingPayModal, {
+ tokenSymbol: "USDC",
+ tokenDecimals: 6,
+ recipientAccountId,
+ recipientName,
+ });
+ }, [recipientAccountId, recipientName]);
+
+ return (
+
+ );
+};
diff --git a/src/features/pingpay/components/PingPayModal.tsx b/src/features/pingpay/components/PingPayModal.tsx
new file mode 100644
index 00000000..830788bb
--- /dev/null
+++ b/src/features/pingpay/components/PingPayModal.tsx
@@ -0,0 +1,386 @@
+import { useCallback, useEffect, useState } from "react";
+
+import { create, useModal } from "@ebay/nice-modal-react";
+import { Check, Copy, ExternalLink } from "lucide-react";
+import { useForm } from "react-hook-form";
+
+import { nearProtocolClient } from "@/common/blockchains/near-protocol";
+import { floatToIndivisible } from "@/common/lib/format";
+import { TextField } from "@/common/ui/form/components";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Form,
+ FormField,
+} from "@/common/ui/layout/components";
+import { useWalletUserSession } from "@/common/wallet";
+
+import { PINGPAY_USDC_TOKEN_CONTRACT_ID } from "../constants";
+
+// Fallback registration deposit when the FT contract's storage_balance_bounds
+// is unavailable. Mirrors what direct-ft-donation.ts uses for headroom.
+const STORAGE_DEPOSIT_FALLBACK_YOCTO = "100000000000000000000000"; // 0.1 NEAR
+
+const ftContractIdForSymbol = (symbol: string): string | null => {
+ if (symbol.toUpperCase() === "USDC") return PINGPAY_USDC_TOKEN_CONTRACT_ID;
+ return null;
+};
+
+export type PingPayModalProps = {
+ tokenSymbol: string;
+ tokenDecimals: number;
+} & (
+ | {
+ campaignId: string | number;
+ campaignName?: string;
+ recipientAccountId?: never;
+ recipientName?: never;
+ }
+ | {
+ recipientAccountId: string;
+ recipientName?: string;
+ campaignId?: never;
+ campaignName?: never;
+ }
+);
+
+type PingPayFormValues = {
+ amount: number;
+};
+
+export const PingPayModal = create((props: PingPayModalProps) => {
+ const { tokenSymbol, tokenDecimals } = props;
+ const campaignId = "campaignId" in props ? props.campaignId : undefined;
+ const campaignName = "campaignName" in props ? props.campaignName : undefined;
+
+ const recipientAccountId = "recipientAccountId" in props ? props.recipientAccountId : undefined;
+
+ const recipientName = "recipientName" in props ? props.recipientName : undefined;
+ const isCampaign = campaignId !== undefined;
+
+ const self = useModal();
+ const walletUser = useWalletUserSession();
+
+ const donorAccountId =
+ walletUser.isSignedIn && walletUser.accountId ? walletUser.accountId : null;
+
+ const form = useForm({
+ mode: "onChange",
+ defaultValues: { amount: 0.1 },
+ });
+
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [sessionUrl, setSessionUrl] = useState(null);
+ const [copied, setCopied] = useState(false);
+ // null = not yet checked; true = recipient is unregistered on the FT contract
+ // and will need a one-time storage_deposit before PingPay can settle.
+ const [needsStorageDeposit, setNeedsStorageDeposit] = useState(null);
+
+ const ftContractId = ftContractIdForSymbol(tokenSymbol);
+
+ // Pre-flight: check whether the recipient has storage on the FT token contract.
+ // PingPay routes through intents.near and skips the storage_deposit step the
+ // native flow performs; if the recipient isn't registered, the donation
+ // contract refunds the FT transfer.
+ useEffect(() => {
+ if (isCampaign || !recipientAccountId || !ftContractId) {
+ setNeedsStorageDeposit(false);
+ return;
+ }
+
+ let cancelled = false;
+ setNeedsStorageDeposit(null);
+
+ const tokenClient = nearProtocolClient.contractApi({ contractId: ftContractId });
+
+ tokenClient
+ .view<{ account_id: string }, { total: string; available: string } | null>(
+ "storage_balance_of",
+ { args: { account_id: recipientAccountId } },
+ )
+ .then((balance) => {
+ if (cancelled) return;
+ setNeedsStorageDeposit(balance === null);
+ })
+ .catch(() => {
+ if (cancelled) return;
+ // On view failure, don't block the user — assume registered and let
+ // the on-chain flow surface a real error if it fails.
+ setNeedsStorageDeposit(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [isCampaign, recipientAccountId, ftContractId]);
+
+ const close = useCallback(() => {
+ self.hide();
+ self.remove();
+ }, [self]);
+
+ const handleCopy = useCallback(() => {
+ if (!sessionUrl) return;
+
+ navigator.clipboard.writeText(sessionUrl).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ }, [sessionUrl]);
+
+ const handlePayNow = useCallback(() => {
+ if (!sessionUrl) return;
+ window.open(sessionUrl, "_blank", "noopener,noreferrer");
+ close();
+ }, [sessionUrl, close]);
+
+ const onSubmit = form.handleSubmit(async ({ amount: amountValue }) => {
+ const amountFloat = Number(amountValue);
+
+ if (!Number.isFinite(amountFloat) || amountFloat <= 0) {
+ setError("Please enter a valid positive amount.");
+ return;
+ }
+
+ if (!donorAccountId) {
+ setError("Please connect your NEAR wallet before creating a payment link.");
+ return;
+ }
+
+ setError(null);
+ setIsSubmitting(true);
+
+ try {
+ // Register the recipient on the FT contract before creating the PingPay
+ // session. If the donor cancels or signing fails, abort — no payment link
+ // is created, so no funds are at risk.
+ if (!isCampaign && recipientAccountId && ftContractId && needsStorageDeposit === true) {
+ try {
+ const tokenClient = nearProtocolClient.contractApi({ contractId: ftContractId });
+
+ let depositYocto = STORAGE_DEPOSIT_FALLBACK_YOCTO;
+
+ try {
+ const bounds = await tokenClient.view<{}, { min: string; max: string }>(
+ "storage_balance_bounds",
+ );
+
+ // Prefer the contract's declared minimum; fall back if missing.
+ if (bounds?.min) depositYocto = bounds.min;
+ } catch {
+ // keep fallback
+ }
+
+ await tokenClient.call("storage_deposit", {
+ args: { account_id: recipientAccountId },
+ deposit: depositYocto,
+ gas: "100000000000000",
+ });
+
+ setNeedsStorageDeposit(false);
+ } catch (storageErr) {
+ console.error("FT storage_deposit pre-flight failed:", storageErr);
+
+ setError(
+ "Could not register this project on the token contract. " +
+ "Please try again or use a different wallet.",
+ );
+
+ setIsSubmitting(false);
+ return;
+ }
+ }
+
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
+ const indivisibleAmount = floatToIndivisible(amountFloat, tokenDecimals).toString();
+
+ const endpoint = isCampaign
+ ? "/api/pingpay/create-checkout"
+ : "/api/pingpay/create-project-checkout";
+
+ const successUrl = isCampaign
+ ? `${origin}/pingpay/callback?type=campaign&id=${campaignId}&status=success`
+ : `${origin}/pingpay/callback?type=project&id=${encodeURIComponent(recipientAccountId ?? "")}&status=success`;
+
+ const cancelUrl = isCampaign
+ ? `${origin}/pingpay/callback?type=campaign&id=${campaignId}&status=cancelled`
+ : `${origin}/pingpay/callback?type=project&id=${encodeURIComponent(recipientAccountId ?? "")}&status=cancelled`;
+
+ const requestBody = isCampaign
+ ? {
+ amount: indivisibleAmount,
+ asset: { chain: "NEAR", symbol: tokenSymbol },
+ campaignId,
+ donorAccountId,
+ successUrl,
+ cancelUrl,
+ }
+ : {
+ amount: indivisibleAmount,
+ asset: { chain: "NEAR", symbol: tokenSymbol },
+ recipientAccountId,
+ donorAccountId,
+ successUrl,
+ cancelUrl,
+ };
+
+ const res = await fetch(endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(requestBody),
+ });
+
+ const data = await res.json();
+ const url = data?.session?.sessionUrl ?? data?.sessionUrl;
+ const sid = data?.session?.sessionId;
+
+ if (!res.ok || !url) {
+ setError(data?.error ?? "Failed to create payment link.");
+ return;
+ }
+
+ // Store sessionId + donor so the callback page can attribute the sync
+ if (sid) {
+ try {
+ localStorage.setItem("pingpay_session_id", sid);
+ localStorage.setItem("pingpay_donor_account_id", donorAccountId);
+ } catch {
+ // localStorage unavailable, sync will be best-effort
+ }
+ }
+
+ setSessionUrl(url);
+ } catch {
+ setError("Failed to create payment link.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ });
+
+ const title = isCampaign
+ ? campaignName
+ ? `Donate to ${campaignName} Campaign`
+ : "Create Payment link"
+ : recipientName
+ ? `Donate to ${recipientName}`
+ : "Create Payment link";
+
+ return (
+
+ );
+});
diff --git a/src/features/pingpay/constants.ts b/src/features/pingpay/constants.ts
new file mode 100644
index 00000000..b5d6f997
--- /dev/null
+++ b/src/features/pingpay/constants.ts
@@ -0,0 +1,7 @@
+/**
+ * PingPay routes USDC donations through this NEAR Intents-wrapped USDC token
+ * contract. Used to pre-check / register recipient storage on the FT contract
+ * so PingPay donations don't get refunded.
+ */
+export const PINGPAY_USDC_TOKEN_CONTRACT_ID =
+ "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1";
diff --git a/src/features/pingpay/index.ts b/src/features/pingpay/index.ts
new file mode 100644
index 00000000..f2a9cf9d
--- /dev/null
+++ b/src/features/pingpay/index.ts
@@ -0,0 +1,2 @@
+export { FastDonateButton } from "./components/FastDonateButton";
+export { FastDonateToProjectButton } from "./components/FastDonateToProjectButton";
diff --git a/src/layout/profile/_deprecated/DonationItem.tsx b/src/layout/profile/_deprecated/DonationItem.tsx
index a73bfb0f..b533aea3 100644
--- a/src/layout/profile/_deprecated/DonationItem.tsx
+++ b/src/layout/profile/_deprecated/DonationItem.tsx
@@ -76,22 +76,33 @@ export const DonationItem = ({
recipient: _recipient,
donated_at,
token,
+ message,
} = donation;
+ const isPingPay = (message ?? "").startsWith("[via PingPay]");
+
const { id: donorId } = donor;
const potId = pot?.id;
const baseCurrency = pot?.base_currency;
const recipient = _recipient ?? { id: "" };
const { id: recipientId } = recipient;
const paidAt = new Date(donated_at).getTime();
- const ftId = token.id || baseCurrency;
- const decimals = token.decimals;
+ const ftId = (token as any).account || (token as any).id || baseCurrency;
+ // The indexer reports decimals=24 for wrapped Intents stablecoins (USDC/USDT)
+ // even though they actually use 6 decimals. Override by symbol as a workaround.
+ const symbolDecimalsOverride: Record = { USDC: 6, USDT: 6 };
+
+ const decimals =
+ token.symbol && symbolDecimalsOverride[token.symbol.toUpperCase()] !== undefined
+ ? symbolDecimalsOverride[token.symbol.toUpperCase()]
+ : token.decimals;
+
const isPot = !!potId;
const donationAmount = parseFloat(
Big(total_amount || amount)
.div(Big(10).pow(ftId === "near" ? 24 : decimals || 24))
- .toFixed(2),
+ .toFixed(decimals && decimals <= 6 ? 4 : 2),
);
const url = isPot
@@ -124,18 +135,32 @@ export const DonationItem = ({
)}{" "}
{name}
- {isPot ? "Matched donation" : "Direct donation"}
+
+ {isPot ? "Matched donation" : "Direct donation"}
+ {isPingPay && (
+
+ via PingPay
+
+ )}
+
- {ftId === NATIVE_TOKEN_ID ? (
+ {ftId === NATIVE_TOKEN_ID || token.symbol?.toUpperCase() === "NEAR" ? (
- ) : (
-

- )}
+ ) : token.icon ? (
+

{
+ (e.currentTarget as HTMLImageElement).style.display = "none";
+ }}
+ />
+ ) : null}
- {addTrailingZeros(donationAmount)}
+ {addTrailingZeros(donationAmount)} {token.symbol ?? ""}
{getTimePassed(paidAt, true)} ago
diff --git a/src/layout/profile/_deprecated/accounts.ts b/src/layout/profile/_deprecated/accounts.ts
index 59fb8c6d..c189e517 100644
--- a/src/layout/profile/_deprecated/accounts.ts
+++ b/src/layout/profile/_deprecated/accounts.ts
@@ -137,7 +137,7 @@ export const getAccountDonationsReceived = async ({
limit?: number;
}) => {
const res = await fetch(
- `${INDEXER_API_ENDPOINT_URL}/api/v1/accounts/${accountId}/donations_received?limit=${limit || 9999}`,
+ `${INDEXER_API_ENDPOINT_URL}/api/v1/accounts/${accountId}/donations_received?page_size=${limit || 9999}`,
);
const json = await res.json();
diff --git a/src/layout/profile/components/summary.tsx b/src/layout/profile/components/summary.tsx
index 91da771e..6626b3fa 100644
--- a/src/layout/profile/components/summary.tsx
+++ b/src/layout/profile/components/summary.tsx
@@ -21,6 +21,7 @@ import {
useAccountSocialProfile,
} from "@/entities/_shared/account";
import { DonateToAccountButton } from "@/features/donation";
+import { FastDonateToProjectButton } from "@/features/pingpay";
import { rootPathnames, routeSelectors } from "@/navigation";
const Linktree: React.FC = ({ accountId }) => {
@@ -159,6 +160,8 @@ export const ProfileLayoutSummary: React.FC = ({ acco
+
+
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index c24fa6fb..e5571d51 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -14,10 +14,12 @@ import { AppProps } from "next/app";
import { Lora } from "next/font/google";
import Head from "next/head";
import { Provider as ReduxProvider } from "react-redux";
+import { useSWRConfig } from "swr";
import { APP_METADATA } from "@/common/constants";
import { TooltipProvider } from "@/common/ui/layout/components";
import { Toaster } from "@/common/ui/layout/components/molecules/toaster";
+import { useToast } from "@/common/ui/layout/hooks";
import { cn } from "@/common/ui/layout/utils";
import { WalletUserSessionProvider } from "@/common/wallet";
import { AppBar } from "@/layout/components/app-bar";
@@ -38,6 +40,40 @@ type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
+function PingPayBroadcastListener() {
+ const { mutate } = useSWRConfig();
+ const { toast } = useToast();
+
+ useEffect(() => {
+ let channel: BroadcastChannel | null = null;
+
+ try {
+ channel = new BroadcastChannel("pingpay");
+
+ channel.onmessage = (e) => {
+ if (e.data?.type === "pingpay-complete") {
+ if (e.data?.paymentStatus === "success") {
+ toast({
+ title: "Donation Successful",
+ description: "Your donation has been recorded.",
+ });
+ }
+
+ mutate(() => true, undefined, { revalidate: true });
+ }
+ };
+ } catch {
+ // BroadcastChannel unsupported
+ }
+
+ return () => {
+ channel?.close();
+ };
+ }, [mutate, toast]);
+
+ return null;
+}
+
export default function RootLayout({ Component, pageProps }: AppPropsWithLayout) {
useEffect(() => void store.dispatch.core.init(), []);
@@ -51,6 +87,7 @@ export default function RootLayout({ Component, pageProps }: AppPropsWithLayout)
+