diff --git a/__tests__/marketLiquidity.test.ts b/__tests__/marketLiquidity.test.ts index e04c5270..39b97d32 100644 --- a/__tests__/marketLiquidity.test.ts +++ b/__tests__/marketLiquidity.test.ts @@ -14,6 +14,8 @@ import { nearestFillableMessage, noLiquidityMessage, shouldSuppressNoProviderForLiquidity, + minOffRampTokenAmount, + MIN_SWAP_USD, type LiquidityCorridor, } from "../app/lib/marketLiquidity"; @@ -662,3 +664,28 @@ describe("copy", () => { ); }); }); + +describe("minOffRampTokenAmount", () => { + it("returns MIN_SWAP_USD for USD-pegged tokens", () => { + expect(minOffRampTokenAmount("USDC", null)).toEqual({ + status: "ok", + min: MIN_SWAP_USD, + }); + }); + + it("scales cNGN by rate when available", () => { + expect(minOffRampTokenAmount("cNGN", 1500)).toEqual({ + status: "ok", + min: MIN_SWAP_USD * 1500, + }); + }); + + it("returns unavailable when cNGN rate is missing", () => { + expect(minOffRampTokenAmount("cNGN", null)).toEqual({ + status: "cngn_rate_unavailable", + }); + expect(minOffRampTokenAmount("CNGN", 0)).toEqual({ + status: "cngn_rate_unavailable", + }); + }); +}); diff --git a/app/components/bridge/BridgeForm.tsx b/app/components/bridge/BridgeForm.tsx index 8168957c..5c610826 100644 --- a/app/components/bridge/BridgeForm.tsx +++ b/app/components/bridge/BridgeForm.tsx @@ -9,6 +9,7 @@ import { useBalance, useTokens, useInjectedWallet } from "@/app/context"; import { useBridgeQuote, useBridgeExecute, useBridgeStatus } from "@/app/hooks/bridge"; import { selectEngine, toRawAmount, bridgeFeeInReceivingToken } from "@/app/lib/bridge"; import type { BridgeLeg, BridgeEngine } from "@/app/lib/bridge"; +import { minOffRampTokenAmount } from "@/app/lib/marketLiquidity"; import { BridgeRouteSelector } from "./BridgeRouteSelector"; import { BridgeQuoteCard } from "./BridgeQuoteCard"; import { @@ -20,13 +21,14 @@ import { } from "hugeicons-react"; import { useDelegationContractAuth } from "@/app/hooks/useEIP7702Account"; import { primaryBtnClasses, outlineBtnClasses } from "../Styles"; -import { classNames, formatTokenAmount, getExplorerLink } from "@/app/utils"; +import { classNames, formatTokenAmount, getExplorerLink, formatNumberWithCommas } from "@/app/utils"; import type { MobileSheetView } from "@/app/types"; import { saveTransaction } from "@/app/api/aggregator"; import { networks } from "@/app/mocks"; import Link from "next/link"; import { mapReportAndAct } from "@/app/lib/toastMappedError"; import { format } from "date-fns"; +import { useCNGNRate, CNGN_CROSS_CHAIN_QUOTE_NETWORK } from "@/app/hooks/useCNGNRate"; const CONVERSION_FAILED_MESSAGE = "Please try again."; @@ -43,6 +45,8 @@ interface BridgeFormProps { onBridgeSubmit?: (info: BridgeSubmitInfo) => void; } +const CNGN_RATE_UNAVAILABLE_MESSAGE = "No available quote"; + export const BridgeForm: React.FC = ({ onClose, setCurrentView, @@ -82,6 +86,9 @@ export const BridgeForm: React.FC = ({ const { allTokens } = useTokens(); const { signDelegationAuthorization } = useDelegationContractAuth(); const { refreshBalance } = useBalance(); + const { rate: cngnRate } = useCNGNRate({ + network: CNGN_CROSS_CHAIN_QUOTE_NETWORK, + }); const [step, setStep] = useState<"form" | "status" | "failed">("form"); const [isFinalizing, setIsFinalizing] = useState(false); const [failureMessage, setFailureMessage] = useState(null); @@ -119,6 +126,25 @@ export const BridgeForm: React.FC = ({ 10, ); + const parsedAmount = Number(amount); + const minConvertResult = from + ? minOffRampTokenAmount(from.token, cngnRate) + : null; + const cngnRateUnavailable = + minConvertResult?.status === "cngn_rate_unavailable"; + const minConvertAmount = + minConvertResult?.status === "ok" ? minConvertResult.min : null; + const amountBelowMin = + !cngnRateUnavailable && + minConvertAmount !== null && + Number.isFinite(parsedAmount) && + parsedAmount > 0 && + parsedAmount < minConvertAmount; + const convertMinMessage = + amountBelowMin && from && minConvertAmount !== null + ? `Minimum amount is ${formatNumberWithCommas(minConvertAmount)} ${from.token}` + : null; + const embeddedWallet = wallets.find((w) => w.walletClientType === "privy"); // Support both Privy embedded wallet and injected wallet @@ -165,7 +191,12 @@ export const BridgeForm: React.FC = ({ evmAddress, starknetAddress, slippageBps, - enabled: (authenticated || (isInjectedWallet && injectedReady)) && !routeUnsupported && !!(evmAddress || starknetAddress), + enabled: + (authenticated || (isInjectedWallet && injectedReady)) && + !routeUnsupported && + !!(evmAddress || starknetAddress) && + !cngnRateUnavailable && + (minConvertAmount === null || parsedAmount >= minConvertAmount), getAccessToken, getInjectedToken: isInjectedWallet && injectedReady ? getInjectedTokenPassive : undefined, @@ -237,12 +268,25 @@ export const BridgeForm: React.FC = ({ const handleConfirm = async () => { if (!quote || !from || !to) return; const parsedAmount = Number(amount); + const minResult = minOffRampTokenAmount(from.token, cngnRate); const rawAmount = toRawAmount(amount, from.decimals); if (!Number.isFinite(parsedAmount) || parsedAmount <= 0 || rawAmount === "0") { setFailureMessage("Enter a valid amount for the selected token."); setStep("failed"); return; } + if (minResult.status === "cngn_rate_unavailable") { + setFailureMessage(CNGN_RATE_UNAVAILABLE_MESSAGE); + setStep("failed"); + return; + } + if (parsedAmount < minResult.min) { + setFailureMessage( + `Minimum amount is ${formatNumberWithCommas(minResult.min)} ${from.token}`, + ); + setStep("failed"); + return; + } const fromWithAmount: BridgeLeg = { ...from, amount, @@ -331,22 +375,28 @@ export const BridgeForm: React.FC = ({ // never ran (unauthenticated, wallet address not ready) leaves `quote` null too, and // treating that as a dead route showed this error to every user before they connected. const noRailAvailable = - routeUnsupported || - (quoteFetched && - !quoteLoading && - !quoteError && - quote === null && - !!from && - !!to && - parseFloat(amount || "0") > 0); + !cngnRateUnavailable && + (routeUnsupported || + (quoteFetched && + !quoteLoading && + !quoteError && + quote === null && + !!from && + !!to && + minConvertAmount !== null && + parsedAmount >= minConvertAmount && + !amountBelowMin)); const canConfirm = + !cngnRateUnavailable && !noRailAvailable && !isQuoteExpired && + !amountBelowMin && !!quote && !quoteLoading && !quoteError && - parseFloat(amount || "0") > 0 && + minConvertAmount !== null && + parsedAmount >= minConvertAmount && !!from && !!to; @@ -396,6 +446,7 @@ export const BridgeForm: React.FC = ({ engine={engine} timeEstimate={timeEstimate} isQuoteLoading={quoteLoading} + amountHasError={amountBelowMin} /> {fromNetworkName !== toNetworkName && ( @@ -408,6 +459,18 @@ export const BridgeForm: React.FC = ({ )} + {cngnRateUnavailable && ( +
+ {CNGN_RATE_UNAVAILABLE_MESSAGE} +
+ )} + + {amountBelowMin && convertMinMessage && ( +
+ {convertMinMessage} +
+ )} + {noRailAvailable ? (
No conversion rail is available for this route at this time. diff --git a/app/components/bridge/BridgeRouteSelector.tsx b/app/components/bridge/BridgeRouteSelector.tsx index ec0bd4f8..45ccb5ae 100644 --- a/app/components/bridge/BridgeRouteSelector.tsx +++ b/app/components/bridge/BridgeRouteSelector.tsx @@ -30,6 +30,8 @@ interface BridgeRouteSelectorProps { engine?: "near" | "lifi" | null; timeEstimate?: string; isQuoteLoading?: boolean; + /** When true, styles the From amount like swap validation errors. */ + amountHasError?: boolean; } function getNetworkImgSrc(network: (typeof networks)[0]): string { @@ -83,6 +85,7 @@ export const BridgeRouteSelector: React.FC = ({ onToNetworkChange, outputAmount, isQuoteLoading, + amountHasError = false, }) => { const { crossChainBalances } = useBalance(); const { allTokens } = useTokens(); @@ -307,7 +310,12 @@ export const BridgeRouteSelector: React.FC = ({ value={amount} onChange={(e) => onAmountChange(e.target.value)} placeholder="0.00" - className={amountInputCls} + className={classNames( + "w-full min-w-0 bg-transparent text-2xl font-normal outline-none placeholder-gray-300 dark:placeholder-white/20", + amountHasError + ? "text-red-500 dark:text-red-500" + : "text-gray-900 dark:text-white", + )} /> {from && fromBalance > 0 && (
diff --git a/app/hooks/useSwapButton.ts b/app/hooks/useSwapButton.ts index 9fb0f98d..56c221bc 100644 --- a/app/hooks/useSwapButton.ts +++ b/app/hooks/useSwapButton.ts @@ -1,7 +1,11 @@ import { usePrivy } from "@privy-io/react-auth"; import { UseFormWatch } from "react-hook-form"; import { useInjectedWallet } from "../context"; -import type { LiquiditySegment } from "../lib/marketLiquidity"; +import { + MIN_SWAP_USD, + minOnRampFiatAmount, + type LiquiditySegment, +} from "../lib/marketLiquidity"; import { validateWalletAddress } from "../lib/validation"; /** Primary CTA when limits require upgrading verification (opens limit / KYC flow from swap). */ @@ -47,6 +51,8 @@ interface UseSwapButtonProps { /** Fillable runs within [min, max]; absent means unknown, so not enforced. */ segments?: LiquiditySegment[]; noLiquidity: boolean; + /** cNGN off-ramp min cannot be computed until cngnRate is positive. */ + cngnMinUnavailable?: boolean; }; /** * Pre-computed insufficient-balance flag from TransactionForm (which guards @@ -113,17 +119,22 @@ export function useSwapButton({ // static limits, the rate-derived floor and live provider capacity together. // Segments additionally reject a hole between two providers' bands, since one // order is filled by one provider. Without bounds the legacy floors stand: - // 0.5 token off-ramp, 0.5×rate on-ramp once a receive token and rate exist. - const amountFloor = amountBounds?.min ?? (isSwapped ? 0.5 * Number(rate) : 0.5); + // $MIN_SWAP_USD off-ramp, same USD equivalent on-ramp once a rate exists. + const amountFloor = + amountBounds?.min ?? + (isSwapped ? minOnRampFiatAmount(Number(rate)) : MIN_SWAP_USD); const amountCeiling = amountBounds?.max ?? Infinity; + const cngnMinUnavailable = amountBounds?.cngnMinUnavailable ?? false; const withinBounds = !amountBounds?.noLiquidity && Number(amountSent) <= amountCeiling && fitsLiquiditySegment(amountBounds?.segments, Number(amountSent)); - const isAmountValid = isSwapped - ? !token || - (withinBounds && Number(rate) > 0 && Number(amountSent) >= amountFloor) - : withinBounds && Number(amountSent) >= amountFloor; + const isAmountValid = cngnMinUnavailable + ? false + : isSwapped + ? !token || + (withinBounds && Number(rate) > 0 && Number(amountSent) >= amountFloor) + : withinBounds && Number(amountSent) >= amountFloor; const isCurrencySelected = Boolean(currency); const totalRequired = Number(amountSent) || 0; @@ -157,6 +168,7 @@ export function useSwapButton({ // Underfunded amounts are short-circuited to Fund wallet / Insufficient // balance; market limits apply once the amount is fundable. if (amountBounds?.noLiquidity && !hasInsufficientBalance) return false; + if (amountBounds?.cngnMinUnavailable && Number(amountSent) > 0) return false; // Underfunded: fund / show shortfall without requiring a live rate quote // (market + rates are paused for those amounts). diff --git a/app/lib/marketLiquidity.ts b/app/lib/marketLiquidity.ts index c049dcc3..51ddd3c1 100644 --- a/app/lib/marketLiquidity.ts +++ b/app/lib/marketLiquidity.ts @@ -8,6 +8,34 @@ import type { RateSide, V2MarketOffer } from "../types"; import { formatNumberWithCommas, getCurrencySymbol } from "../utils"; +import { tokensEqual } from "./token-symbol"; + +/** Minimum swap/convert size in USD terms (product floor across the app). */ +export const MIN_SWAP_USD = 0.5; + +export type MinOffRampTokenAmountResult = + | { status: "ok"; min: number } + | { status: "cngn_rate_unavailable" }; + +/** Off-ramp / convert: minimum Send amount in token units for $MIN_SWAP_USD equivalent. */ +export function minOffRampTokenAmount( + token: string | null | undefined, + cngnRate?: number | null, +): MinOffRampTokenAmountResult { + if (tokensEqual(token, "cNGN")) { + if (cngnRate && cngnRate > 0) { + return { status: "ok", min: MIN_SWAP_USD * cngnRate }; + } + return { status: "cngn_rate_unavailable" }; + } + return { status: "ok", min: MIN_SWAP_USD }; +} + +/** On-ramp: minimum fiat Send amount for $MIN_SWAP_USD equivalent once a rate exists. */ +export function minOnRampFiatAmount(rate: number | null | undefined): number { + if (!rate || rate <= 0) return 0; + return MIN_SWAP_USD * rate; +} /** One continuous run of fillable amounts, in Send-field units. */ export type LiquiditySegment = { min: number; max: number }; diff --git a/app/pages/TransactionForm.tsx b/app/pages/TransactionForm.tsx index c90c7682..5967330e 100644 --- a/app/pages/TransactionForm.tsx +++ b/app/pages/TransactionForm.tsx @@ -54,6 +54,9 @@ import { isAmountFillable, liquidityMaxMessage, liquidityMinMessage, + MIN_SWAP_USD, + minOffRampTokenAmount, + minOnRampFiatAmount, nearestFillableAmount, nearestFillableMessage, noLiquidityMessage, @@ -372,7 +375,8 @@ export const TransactionForm = ({ const liquidityMin = liquidity?.viable ? liquidity.min : null; const liquidityMax = liquidity?.viable ? liquidity.max : null; - let min = 0.5; + let cngnMinUnavailable = false; + let min = MIN_SWAP_USD; let max = 10000; if (swapMode === "onramp") { @@ -383,19 +387,29 @@ export const TransactionForm = ({ max = getOnrampFiatMaxAmount(safeCurrency); // The fiat floor tracks the rate, and is only enforceable once a receive // token and a rate exist; 0 stands for "no floor yet". - min = token && rate && rate > 0 ? 0.5 * rate : 0; - } else if (tokensEqual(token, "cNGN") && cngnRate && cngnRate > 0) { - max = 50000000; - min = 0.5 * cngnRate; + min = token && rate && rate > 0 ? minOnRampFiatAmount(rate) : 0; + } else { + const offRampMin = minOffRampTokenAmount(token, cngnRate); + if (offRampMin.status === "cngn_rate_unavailable") { + cngnMinUnavailable = true; + min = 0; + } else { + min = offRampMin.min; + } + if (tokensEqual(token, "cNGN")) { + max = 50000000; + } } // Live capacity replaces the static ceiling and can only raise the floor. // An unknown or empty book leaves both untouched, so a markets outage // degrades to the previous behavior instead of blocking the form. if (liquidityMax !== null) max = liquidityMax; - if (liquidityMin !== null) min = Math.max(min, liquidityMin); + if (liquidityMin !== null && !cngnMinUnavailable) { + min = Math.max(min, liquidityMin); + } - return { min, max, liquidityMin, liquidityMax }; + return { min, max, liquidityMin, liquidityMax, cngnMinUnavailable }; }, [swapMode, currency, token, rate, cngnRate, liquidity]); const fetchedTokens: Token[] = allTokens[selectedNetwork.chain.name] || []; @@ -809,6 +823,7 @@ export const TransactionForm = ({ max: maxAmountSentValue, liquidityMin, liquidityMax, + cngnMinUnavailable, } = amountBounds; const maxMessage = @@ -823,7 +838,7 @@ export const TransactionForm = ({ formMethods.register("amountSent", { required: { value: true, message: "Amount is required" }, disabled: isSwapped ? !currency : !token, - ...(!isSwapped + ...(!isSwapped && !cngnMinUnavailable ? { min: { value: minAmountSentValue, @@ -836,6 +851,10 @@ export const TransactionForm = ({ message: maxMessage, }, validate: { + cngnRateReady: () => { + if (!cngnMinUnavailable) return true; + return cngnRateError || "No available quote"; + }, decimals: (value: number) => { const decimals = value.toString().split(".")[1]; return ( @@ -849,7 +868,7 @@ export const TransactionForm = ({ // Min fiat depends on rate; only enforce once receive token is chosen and rate exists. if (!token || !rate || rate <= 0) return true; const n = Number(value); - const rateFloor = 0.5 * rate; + const rateFloor = minOnRampFiatAmount(rate); const floor = minAmountSentValue; if (n >= floor) return true; return floor > rateFloor @@ -995,6 +1014,7 @@ export const TransactionForm = ({ ? undefined : liquidity.segments, noLiquidity: hasInsufficientBalance ? false : noLiquidity, + cngnMinUnavailable: amountBounds.cngnMinUnavailable, }, hasInsufficientBalance, });