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
5 changes: 4 additions & 1 deletion src/app/pingpay/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ async function pollSessionStatus(sessionId: string): Promise<{
txHash: string | null;
senderId: string | null;
} | null> {
const maxAttempts = 15;
// 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: {
Expand Down
7 changes: 4 additions & 3 deletions src/entities/campaign/components/CampaignBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +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 { FastDonateButton } from "@/features/pingpay";

import { CampaignProgressBar } from "./CampaignProgressBar";
import { useCampaignForm } from "../hooks/forms";
Expand Down Expand Up @@ -274,14 +274,15 @@ export const CampaignBanner: React.FC<CampaignBannerProps> = ({ campaignId }) =>
{...{ campaignId }}
/>

<FastDonateButton
{/* Create Payment link disabled — campaigns contract for pinpay is not active, needs migration for pingpay integration. */}
{/* <FastDonateButton
recipientAccountId={campaign?.recipient?.id ?? ""}
tokenId={campaign?.token.account ?? NATIVE_TOKEN_ID}
campaignId={campaignId}
campaignName={campaign?.name ?? undefined}
disabled={campaign?.status !== "active"}
className="mb-4"
/>
/> */}

<SocialsShare
shareText={`Support ${campaign?.name} Campaign on ${
Expand Down
101 changes: 100 additions & 1 deletion src/features/pingpay/components/PingPayModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useCallback, useState } from "react";
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 {
Expand All @@ -19,6 +20,17 @@ import {
} 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;
Expand Down Expand Up @@ -66,6 +78,47 @@ export const PingPayModal = create((props: PingPayModalProps) => {
const [error, setError] = useState<string | null>(null);
const [sessionUrl, setSessionUrl] = useState<string | null>(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<boolean | null>(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();
Expand Down Expand Up @@ -104,6 +157,46 @@ export const PingPayModal = create((props: PingPayModalProps) => {
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();

Expand Down Expand Up @@ -217,6 +310,12 @@ export const PingPayModal = create((props: PingPayModalProps) => {
isCampaign ? "this campaign" : "this project"
}.`}
</p>

{needsStorageDeposit === true && (
<p className="mt-2 text-xs italic text-neutral-500">
{`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.`}
</p>
)}
</DialogDescription>

<DialogFooter>
Expand Down
11 changes: 5 additions & 6 deletions src/features/pingpay/constants.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/**
* Reserved for future PingPay-related constants.
* Token gating was previously done here but removed: PingPay's Hosted Checkout
* accepts whatever NEAR-chain asset symbol you pass; the API surfaces an error
* if the asset is unsupported. The button now enables for every campaign and
* defers asset validation to PingPay.
* 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 {};
export const PINGPAY_USDC_TOKEN_CONTRACT_ID =
"17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1";
31 changes: 23 additions & 8 deletions src/layout/profile/_deprecated/DonationItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,22 @@ export const DonationItem = ({
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<string, number> = { 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
Expand Down Expand Up @@ -139,13 +147,20 @@ export const DonationItem = ({
</FundingSrc>
<div className="price tab">
<div className="near-icon">
{ftId === NATIVE_TOKEN_ID ? (
{ftId === NATIVE_TOKEN_ID || token.symbol?.toUpperCase() === "NEAR" ? (
<TokenIcon tokenId={NATIVE_TOKEN_ID} />
) : (
<img className="h-[21px] w-[21px]" src={token.icon} alt="Token icon" />
)}
) : token.icon ? (
<img
className="h-[21px] w-[21px]"
src={token.icon}
alt="Token icon"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : null}
</div>
{addTrailingZeros(donationAmount)}
{addTrailingZeros(donationAmount)} {token.symbol ?? ""}
</div>
<div className="tab date">{getTimePassed(paidAt, true)} ago</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/layout/profile/_deprecated/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading