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 - )} + ) : 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();