1- import { useCallback , useState } from "react" ;
1+ import { useCallback , useEffect , useState } from "react" ;
22
33import { create , useModal } from "@ebay/nice-modal-react" ;
44import { Check , Copy , ExternalLink } from "lucide-react" ;
55import { useForm } from "react-hook-form" ;
66
7+ import { nearProtocolClient } from "@/common/blockchains/near-protocol" ;
78import { floatToIndivisible } from "@/common/lib/format" ;
89import { TextField } from "@/common/ui/form/components" ;
910import {
@@ -19,6 +20,17 @@ import {
1920} from "@/common/ui/layout/components" ;
2021import { 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+
2234export 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 >
0 commit comments