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 ( +
+
+

+ {"Donation processed. You can close this tab."} +

+ + {"Back to Potlock"} + +
+
+ ); +} + +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 ( +

+ + + {sessionUrl ? "Payment Link Ready" : title} + + + {!sessionUrl ? ( +
+ + + ( + field.onChange(e.target.valueAsNumber)} + onClick={undefined} + onBlur={undefined} + onFocus={undefined} + type="number" + placeholder="0.00" + min={0} + step={0.01} + appendix={tokenSymbol} + /> + )} + /> + + {error &&

{error}

} + +

+ {`You'll be redirected to PingPay to pay with card, wallet, or any supported asset. Funds settle as ${tokenSymbol} to ${ + isCampaign ? "this campaign" : "this project" + }.`} +

+ + {needsStorageDeposit === true && ( +

+ {`Note: This project hasn't received ${tokenSymbol} before. A one-time ~0.1 NEAR registration will be signed from your wallet so the donation can settle on-chain.`} +

+ )} +
+ + + + + + +
+ + ) : ( + <> + +
+
+

{sessionUrl}

+
+ +

+ { + "Share this link with anyone to let them complete the payment, or pay now yourself." + } +

+
+
+ + + + + + + + )} +
+
+ ); +}); 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 - )} + ) : token.icon ? ( + 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) +