Skip to content

Commit f2d8fe9

Browse files
authored
Merge pull request #625 from PotLock/feature/pingpay-phase-1
Feature/pingpay phase 1
2 parents 427fa2b + c019fb1 commit f2d8fe9

6 files changed

Lines changed: 137 additions & 20 deletions

File tree

src/app/pingpay/callback/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ async function pollSessionStatus(sessionId: string): Promise<{
111111
txHash: string | null;
112112
senderId: string | null;
113113
} | null> {
114-
const maxAttempts = 15;
114+
// 60 attempts × 2 s = 120 s. PingPay's relayer can take >30 s to submit the
115+
// on-chain tx, especially for first-ever donations to a recipient. The old
116+
// 30 s window silently dropped the sync when settlement was slow.
117+
const maxAttempts = 60;
115118
const delayMs = 2000;
116119

117120
let lastData: {

src/entities/campaign/components/CampaignBanner.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { useWalletUserSession } from "@/common/wallet";
2020
import { AccountProfileLink } from "@/entities/_shared/account";
2121
import { useFungibleToken } from "@/entities/_shared/token";
2222
import { DonateToCampaign } from "@/features/donation";
23-
import { FastDonateButton } from "@/features/pingpay";
23+
// import { FastDonateButton } from "@/features/pingpay";
2424

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

277-
<FastDonateButton
277+
{/* Create Payment link disabled — campaigns contract for pinpay is not active, needs migration for pingpay integration. */}
278+
{/* <FastDonateButton
278279
recipientAccountId={campaign?.recipient?.id ?? ""}
279280
tokenId={campaign?.token.account ?? NATIVE_TOKEN_ID}
280281
campaignId={campaignId}
281282
campaignName={campaign?.name ?? undefined}
282283
disabled={campaign?.status !== "active"}
283284
className="mb-4"
284-
/>
285+
/> */}
285286

286287
<SocialsShare
287288
shareText={`Support ${campaign?.name} Campaign on ${

src/features/pingpay/components/PingPayModal.tsx

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { useCallback, useState } from "react";
1+
import { useCallback, useEffect, useState } from "react";
22

33
import { create, useModal } from "@ebay/nice-modal-react";
44
import { Check, Copy, ExternalLink } from "lucide-react";
55
import { useForm } from "react-hook-form";
66

7+
import { nearProtocolClient } from "@/common/blockchains/near-protocol";
78
import { floatToIndivisible } from "@/common/lib/format";
89
import { TextField } from "@/common/ui/form/components";
910
import {
@@ -19,6 +20,17 @@ import {
1920
} from "@/common/ui/layout/components";
2021
import { useWalletUserSession } from "@/common/wallet";
2122

23+
import { PINGPAY_USDC_TOKEN_CONTRACT_ID } from "../constants";
24+
25+
// Fallback registration deposit when the FT contract's storage_balance_bounds
26+
// is unavailable. Mirrors what direct-ft-donation.ts uses for headroom.
27+
const STORAGE_DEPOSIT_FALLBACK_YOCTO = "100000000000000000000000"; // 0.1 NEAR
28+
29+
const ftContractIdForSymbol = (symbol: string): string | null => {
30+
if (symbol.toUpperCase() === "USDC") return PINGPAY_USDC_TOKEN_CONTRACT_ID;
31+
return null;
32+
};
33+
2234
export type PingPayModalProps = {
2335
tokenSymbol: string;
2436
tokenDecimals: number;
@@ -66,6 +78,47 @@ export const PingPayModal = create((props: PingPayModalProps) => {
6678
const [error, setError] = useState<string | null>(null);
6779
const [sessionUrl, setSessionUrl] = useState<string | null>(null);
6880
const [copied, setCopied] = useState(false);
81+
// null = not yet checked; true = recipient is unregistered on the FT contract
82+
// and will need a one-time storage_deposit before PingPay can settle.
83+
const [needsStorageDeposit, setNeedsStorageDeposit] = useState<boolean | null>(null);
84+
85+
const ftContractId = ftContractIdForSymbol(tokenSymbol);
86+
87+
// Pre-flight: check whether the recipient has storage on the FT token contract.
88+
// PingPay routes through intents.near and skips the storage_deposit step the
89+
// native flow performs; if the recipient isn't registered, the donation
90+
// contract refunds the FT transfer.
91+
useEffect(() => {
92+
if (isCampaign || !recipientAccountId || !ftContractId) {
93+
setNeedsStorageDeposit(false);
94+
return;
95+
}
96+
97+
let cancelled = false;
98+
setNeedsStorageDeposit(null);
99+
100+
const tokenClient = nearProtocolClient.contractApi({ contractId: ftContractId });
101+
102+
tokenClient
103+
.view<{ account_id: string }, { total: string; available: string } | null>(
104+
"storage_balance_of",
105+
{ args: { account_id: recipientAccountId } },
106+
)
107+
.then((balance) => {
108+
if (cancelled) return;
109+
setNeedsStorageDeposit(balance === null);
110+
})
111+
.catch(() => {
112+
if (cancelled) return;
113+
// On view failure, don't block the user — assume registered and let
114+
// the on-chain flow surface a real error if it fails.
115+
setNeedsStorageDeposit(false);
116+
});
117+
118+
return () => {
119+
cancelled = true;
120+
};
121+
}, [isCampaign, recipientAccountId, ftContractId]);
69122

70123
const close = useCallback(() => {
71124
self.hide();
@@ -104,6 +157,46 @@ export const PingPayModal = create((props: PingPayModalProps) => {
104157
setIsSubmitting(true);
105158

106159
try {
160+
// Register the recipient on the FT contract before creating the PingPay
161+
// session. If the donor cancels or signing fails, abort — no payment link
162+
// is created, so no funds are at risk.
163+
if (!isCampaign && recipientAccountId && ftContractId && needsStorageDeposit === true) {
164+
try {
165+
const tokenClient = nearProtocolClient.contractApi({ contractId: ftContractId });
166+
167+
let depositYocto = STORAGE_DEPOSIT_FALLBACK_YOCTO;
168+
169+
try {
170+
const bounds = await tokenClient.view<{}, { min: string; max: string }>(
171+
"storage_balance_bounds",
172+
);
173+
174+
// Prefer the contract's declared minimum; fall back if missing.
175+
if (bounds?.min) depositYocto = bounds.min;
176+
} catch {
177+
// keep fallback
178+
}
179+
180+
await tokenClient.call("storage_deposit", {
181+
args: { account_id: recipientAccountId },
182+
deposit: depositYocto,
183+
gas: "100000000000000",
184+
});
185+
186+
setNeedsStorageDeposit(false);
187+
} catch (storageErr) {
188+
console.error("FT storage_deposit pre-flight failed:", storageErr);
189+
190+
setError(
191+
"Could not register this project on the token contract. " +
192+
"Please try again or use a different wallet.",
193+
);
194+
195+
setIsSubmitting(false);
196+
return;
197+
}
198+
}
199+
107200
const origin = typeof window !== "undefined" ? window.location.origin : "";
108201
const indivisibleAmount = floatToIndivisible(amountFloat, tokenDecimals).toString();
109202

@@ -217,6 +310,12 @@ export const PingPayModal = create((props: PingPayModalProps) => {
217310
isCampaign ? "this campaign" : "this project"
218311
}.`}
219312
</p>
313+
314+
{needsStorageDeposit === true && (
315+
<p className="mt-2 text-xs italic text-neutral-500">
316+
{`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.`}
317+
</p>
318+
)}
220319
</DialogDescription>
221320

222321
<DialogFooter>

src/features/pingpay/constants.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
/**
2-
* Reserved for future PingPay-related constants.
3-
* Token gating was previously done here but removed: PingPay's Hosted Checkout
4-
* accepts whatever NEAR-chain asset symbol you pass; the API surfaces an error
5-
* if the asset is unsupported. The button now enables for every campaign and
6-
* defers asset validation to PingPay.
2+
* PingPay routes USDC donations through this NEAR Intents-wrapped USDC token
3+
* contract. Used to pre-check / register recipient storage on the FT contract
4+
* so PingPay donations don't get refunded.
75
*/
8-
export {};
6+
export const PINGPAY_USDC_TOKEN_CONTRACT_ID =
7+
"17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1";

src/layout/profile/_deprecated/DonationItem.tsx

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,22 @@ export const DonationItem = ({
8787
const recipient = _recipient ?? { id: "" };
8888
const { id: recipientId } = recipient;
8989
const paidAt = new Date(donated_at).getTime();
90-
const ftId = token.id || baseCurrency;
91-
const decimals = token.decimals;
90+
const ftId = (token as any).account || (token as any).id || baseCurrency;
91+
// The indexer reports decimals=24 for wrapped Intents stablecoins (USDC/USDT)
92+
// even though they actually use 6 decimals. Override by symbol as a workaround.
93+
const symbolDecimalsOverride: Record<string, number> = { USDC: 6, USDT: 6 };
94+
95+
const decimals =
96+
token.symbol && symbolDecimalsOverride[token.symbol.toUpperCase()] !== undefined
97+
? symbolDecimalsOverride[token.symbol.toUpperCase()]
98+
: token.decimals;
99+
92100
const isPot = !!potId;
93101

94102
const donationAmount = parseFloat(
95103
Big(total_amount || amount)
96104
.div(Big(10).pow(ftId === "near" ? 24 : decimals || 24))
97-
.toFixed(2),
105+
.toFixed(decimals && decimals <= 6 ? 4 : 2),
98106
);
99107

100108
const url = isPot
@@ -139,13 +147,20 @@ export const DonationItem = ({
139147
</FundingSrc>
140148
<div className="price tab">
141149
<div className="near-icon">
142-
{ftId === NATIVE_TOKEN_ID ? (
150+
{ftId === NATIVE_TOKEN_ID || token.symbol?.toUpperCase() === "NEAR" ? (
143151
<TokenIcon tokenId={NATIVE_TOKEN_ID} />
144-
) : (
145-
<img className="h-[21px] w-[21px]" src={token.icon} alt="Token icon" />
146-
)}
152+
) : token.icon ? (
153+
<img
154+
className="h-[21px] w-[21px]"
155+
src={token.icon}
156+
alt="Token icon"
157+
onError={(e) => {
158+
(e.currentTarget as HTMLImageElement).style.display = "none";
159+
}}
160+
/>
161+
) : null}
147162
</div>
148-
{addTrailingZeros(donationAmount)}
163+
{addTrailingZeros(donationAmount)} {token.symbol ?? ""}
149164
</div>
150165
<div className="tab date">{getTimePassed(paidAt, true)} ago</div>
151166
</div>

src/layout/profile/_deprecated/accounts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export const getAccountDonationsReceived = async ({
137137
limit?: number;
138138
}) => {
139139
const res = await fetch(
140-
`${INDEXER_API_ENDPOINT_URL}/api/v1/accounts/${accountId}/donations_received?limit=${limit || 9999}`,
140+
`${INDEXER_API_ENDPOINT_URL}/api/v1/accounts/${accountId}/donations_received?page_size=${limit || 9999}`,
141141
);
142142

143143
const json = await res.json();

0 commit comments

Comments
 (0)