diff --git a/src/features/donation/components/cross-chain-amount-entry.tsx b/src/features/donation/components/cross-chain-amount-entry.tsx index 8d4d9325..03fffb8d 100644 --- a/src/features/donation/components/cross-chain-amount-entry.tsx +++ b/src/features/donation/components/cross-chain-amount-entry.tsx @@ -488,10 +488,24 @@ export const CrossChainAmountEntry: React.FC = ({ - {isDisabled && ( -
- Please enter a valid amount in {selectedTokenData?.symbol || "USDC"} greater than an - equivalent of 0.1 NEAR. + {isDisabled && price > 0 && nearPrice > 0 && ( +
+ + Please enter a valid amount in {selectedTokenData?.symbol || "USDC"} greater than an + equivalent of 0.1 NEAR (min: {((0.1 * nearPrice) / price).toFixed(4)}{" "} + {selectedTokenData?.symbol || "USDC"}). + +
)} {/* Action Button */} diff --git a/src/features/donation/components/modal-content.tsx b/src/features/donation/components/modal-content.tsx index 5cb4cc7a..ff1225b5 100644 --- a/src/features/donation/components/modal-content.tsx +++ b/src/features/donation/components/modal-content.tsx @@ -86,8 +86,16 @@ export const DonationModalContent: React.FC = ({ const { isLoading: isDonationConfigLoading, data: donationConfig } = donationContractHooks.useConfig(); - const { form, matchingPots, isDisabled, onSubmit, totalAmountFloat, isGroupDonation } = - useDonationForm(props); + const { + form, + matchingPots, + isDisabled, + onSubmit, + totalAmountFloat, + isGroupDonation, + crossChainMinAmount, + crossChainTokenSymbol, + } = useDonationForm(props); const isCampaignDonation = "campaignId" in props; const isPotDonation = "potId" in props; @@ -141,6 +149,8 @@ export const DonationModalContent: React.FC = ({ matchingPots={matchingPots} {...props} onTokenDataChange={setSelectedTokenData} + crossChainMinAmount={crossChainMinAmount} + crossChainTokenSymbol={crossChainTokenSymbol} /> ); } else if ("potId" in props || "listId" in props) { diff --git a/src/features/donation/components/single-recipient-allocation.tsx b/src/features/donation/components/single-recipient-allocation.tsx index 11c8f9eb..5df58cfa 100644 --- a/src/features/donation/components/single-recipient-allocation.tsx +++ b/src/features/donation/components/single-recipient-allocation.tsx @@ -40,11 +40,21 @@ export type DonationSingleRecipientAllocationProps = Partial & DonationAllocationInputs & { matchingPots?: Pot[]; onTokenDataChange?: (data: { blockchain: string; tokenData?: any } | null) => void; + crossChainMinAmount?: number; + crossChainTokenSymbol?: string; }; export const DonationSingleRecipientAllocation: React.FC< DonationSingleRecipientAllocationProps -> = ({ form, accountId, matchingPots, campaignId, onTokenDataChange }) => { +> = ({ + form, + accountId, + matchingPots, + campaignId, + onTokenDataChange, + crossChainMinAmount, + crossChainTokenSymbol, +}) => { const walletUser = useWalletUserSession(); const [selectedTokenData, setSelectedTokenData] = useState<{ @@ -332,6 +342,35 @@ export const DonationSingleRecipientAllocation: React.FC< )} /> )} + + {/* Cross-chain minimum amount warning with quick-fix button */} + {isCrossChainToken && + crossChainMinAmount !== undefined && + crossChainMinAmount > 0 && + amount !== undefined && + parseFloat(amount.toString()) > 0 && + parseFloat(amount.toString()) < crossChainMinAmount && ( +
+ + Minimum amount is{" "} + + {crossChainMinAmount.toFixed(4)} {crossChainTokenSymbol || "tokens"} + {" "} + (equivalent to 0.1 NEAR). + + +
+ )} ); diff --git a/src/features/donation/hooks/form.ts b/src/features/donation/hooks/form.ts index b1e19a61..db2a2d32 100644 --- a/src/features/donation/hooks/form.ts +++ b/src/features/donation/hooks/form.ts @@ -17,6 +17,7 @@ import { useFungibleToken } from "@/entities/_shared/token"; import { extractMatchingPots } from "@/entities/pot"; import { useDispatch } from "@/store/hooks"; +import { useCrossChainTokens } from "./cross-chain-tokens"; import { DONATION_DEFAULT_MIN_AMOUNT_FLOAT, DONATION_INSUFFICIENT_BALANCE_ERROR, @@ -145,6 +146,42 @@ export const useDonationForm = ({ cachedTokenId, ...params }: DonationFormParams enabled: !isCrossChainToken, }); + // Fetch cross-chain token list to get NEAR price and selected token price + const { data: crossChainTokenList } = useCrossChainTokens(); + + const { crossChainNearPrice, crossChainTokenPrice, crossChainTokenSymbol } = useMemo(() => { + if (!isCrossChainToken || !crossChainTokenList || !values.tokenId) { + return { crossChainNearPrice: 0, crossChainTokenPrice: 0, crossChainTokenSymbol: "" }; + } + + const nearToken = crossChainTokenList.find( + (t) => t.symbol === "wNEAR" || t.assetId === "nep141:wrap.near", + ); + + const parts = values.tokenId.split(":"); + const blockchain = parts[0]; + const assetId = parts.slice(1).join(":"); + + const selectedToken = crossChainTokenList.find( + (t) => t.assetId === assetId && t.blockchain.toLowerCase() === blockchain.toLowerCase(), + ); + + return { + crossChainNearPrice: nearToken?.price ?? 0, + crossChainTokenPrice: selectedToken?.price ?? 0, + crossChainTokenSymbol: selectedToken?.symbol ?? "", + }; + }, [isCrossChainToken, crossChainTokenList, values.tokenId]); + + // Minimum amount in the selected cross-chain token equivalent to 0.1 NEAR + const crossChainMinAmount = useMemo(() => { + if (crossChainTokenPrice > 0 && crossChainNearPrice > 0) { + return (0.1 * crossChainNearPrice) / crossChainTokenPrice; + } + + return 0; + }, [crossChainNearPrice, crossChainTokenPrice]); + const { data: pot } = indexer.usePot({ enabled: isGroupPotDonation || isSingleRecipientPotDonation, potId: groupDonationPotId ?? values.potAccountId ?? NOOP_STRING, @@ -296,8 +333,21 @@ export const useDonationForm = ({ cachedTokenId, ...params }: DonationFormParams } } + //* Cross-chain minimum amount validation (0.1 NEAR equivalent) + else if ( + isCrossChainToken && + crossChainMinAmount > 0 && + Big(parsedAmount).lt(crossChainMinAmount) + ) { + const errorMessage = `Amount must be at least ${crossChainMinAmount.toFixed(4)} ${crossChainTokenSymbol || "tokens"} (equivalent to 0.1 NEAR).`; + + if (customErrors?.amount?.message !== errorMessage || self.formState.isValid) { + setCustomErrors({ amount: { message: errorMessage } }); + } + } + //* Addressing single-recipient and group donation scenarios with evenly distributed funds - //* Skip minimum amount validation for cross-chain tokens (they don't have min requirements) + //* Skip minimum amount validation for cross-chain tokens (handled above) else if ( !isCrossChainToken && minTotalAmountFloat !== undefined && @@ -344,6 +394,8 @@ export const useDonationForm = ({ cachedTokenId, ...params }: DonationFormParams } }, [ customErrors, + crossChainMinAmount, + crossChainTokenSymbol, isCrossChainToken, isFtDonation, isGroupDonation, @@ -375,5 +427,8 @@ export const useDonationForm = ({ cachedTokenId, ...params }: DonationFormParams // TODO: Likely not needed to be exposed anymore, try using `amount` everywhere // TODO: in the consuming code instead and remove this if no issues detected. totalAmountFloat, + crossChainMinAmount, + crossChainTokenSymbol, + isCrossChainToken, }; };