diff --git a/src/app/pingpay/callback/page.tsx b/src/app/pingpay/callback/page.tsx
index 2dc9d30c..f2dc368d 100644
--- a/src/app/pingpay/callback/page.tsx
+++ b/src/app/pingpay/callback/page.tsx
@@ -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: {
diff --git a/src/entities/campaign/components/CampaignBanner.tsx b/src/entities/campaign/components/CampaignBanner.tsx
index 74feaeab..18439fdb 100644
--- a/src/entities/campaign/components/CampaignBanner.tsx
+++ b/src/entities/campaign/components/CampaignBanner.tsx
@@ -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";
@@ -274,14 +274,15 @@ export const CampaignBanner: React.FC = ({ campaignId }) =>
{...{ campaignId }}
/>
-
+ /> */}
{
+ if (symbol.toUpperCase() === "USDC") return PINGPAY_USDC_TOKEN_CONTRACT_ID;
+ return null;
+};
+
export type PingPayModalProps = {
tokenSymbol: string;
tokenDecimals: number;
@@ -66,6 +78,47 @@ export const PingPayModal = create((props: PingPayModalProps) => {
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();
@@ -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();
@@ -217,6 +310,12 @@ export const PingPayModal = create((props: PingPayModalProps) => {
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.`}
+
+ )}
diff --git a/src/features/pingpay/constants.ts b/src/features/pingpay/constants.ts
index a1c09a0d..b5d6f997 100644
--- a/src/features/pingpay/constants.ts
+++ b/src/features/pingpay/constants.ts
@@ -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";
diff --git a/src/layout/profile/_deprecated/DonationItem.tsx b/src/layout/profile/_deprecated/DonationItem.tsx
index 2ed347c6..b533aea3 100644
--- a/src/layout/profile/_deprecated/DonationItem.tsx
+++ b/src/layout/profile/_deprecated/DonationItem.tsx
@@ -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 = { 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
@@ -139,13 +147,20 @@ export const DonationItem = ({
- {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();