Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions src/features/donation/components/cross-chain-amount-entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -488,10 +488,24 @@ export const CrossChainAmountEntry: React.FC<CrossChainAmountEntryProps> = ({
</div>
</div>
</div>
{isDisabled && (
<div className="text-sm text-red-500">
Please enter a valid amount in {selectedTokenData?.symbol || "USDC"} greater than an
equivalent of 0.1 NEAR.
{isDisabled && price > 0 && nearPrice > 0 && (
<div className="flex items-center gap-2 text-sm text-red-500">
<span>
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"}).
</span>
<button
type="button"
title="Set to minimum amount"
className="inline-flex shrink-0 items-center justify-center rounded-md border border-red-300 bg-red-50 px-2 py-1 text-xs font-medium text-red-600 transition-colors hover:bg-red-100 hover:text-red-700"
onClick={() => {
const minAmount = ((0.1 * nearPrice) / price) * 1.01; // add 1% buffer to safely clear the threshold
form.setValue("amount", parseFloat(minAmount.toFixed(4)));
}}
>
✏️ Update
</button>
</div>
)}
{/* Action Button */}
Expand Down
14 changes: 12 additions & 2 deletions src/features/donation/components/modal-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,16 @@ export const DonationModalContent: React.FC<DonationModalContentProps> = ({
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;
Expand Down Expand Up @@ -141,6 +149,8 @@ export const DonationModalContent: React.FC<DonationModalContentProps> = ({
matchingPots={matchingPots}
{...props}
onTokenDataChange={setSelectedTokenData}
crossChainMinAmount={crossChainMinAmount}
crossChainTokenSymbol={crossChainTokenSymbol}
/>
);
} else if ("potId" in props || "listId" in props) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,21 @@ export type DonationSingleRecipientAllocationProps = Partial<ByAccountId> &
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<{
Expand Down Expand Up @@ -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 && (
<div className="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-600">
<span>
Minimum amount is{" "}
<strong>
{crossChainMinAmount.toFixed(4)} {crossChainTokenSymbol || "tokens"}
</strong>{" "}
(equivalent to 0.1 NEAR).
</span>
<button
type="button"
title="Set to minimum amount"
className="inline-flex shrink-0 items-center justify-center rounded-md border border-red-300 bg-white px-2 py-1 text-xs font-medium text-red-600 transition-colors hover:bg-red-100 hover:text-red-700"
onClick={() => {
const minAmount = crossChainMinAmount * 1.01; // add 1% buffer
form.setValue("amount", parseFloat(minAmount.toFixed(4)));
}}
>
✏️ Update
</button>
</div>
)}
</DialogDescription>
</>
);
Expand Down
57 changes: 56 additions & 1 deletion src/features/donation/hooks/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -344,6 +394,8 @@ export const useDonationForm = ({ cachedTokenId, ...params }: DonationFormParams
}
}, [
customErrors,
crossChainMinAmount,
crossChainTokenSymbol,
isCrossChainToken,
isFtDonation,
isGroupDonation,
Expand Down Expand Up @@ -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,
};
};
Loading