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
27 changes: 27 additions & 0 deletions __tests__/marketLiquidity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
nearestFillableMessage,
noLiquidityMessage,
shouldSuppressNoProviderForLiquidity,
minOffRampTokenAmount,
MIN_SWAP_USD,
type LiquidityCorridor,
} from "../app/lib/marketLiquidity";

Expand Down Expand Up @@ -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",
});
});
});
85 changes: 74 additions & 11 deletions app/components/bridge/BridgeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.";

Expand All @@ -43,6 +45,8 @@ interface BridgeFormProps {
onBridgeSubmit?: (info: BridgeSubmitInfo) => void;
}

const CNGN_RATE_UNAVAILABLE_MESSAGE = "No available quote";

export const BridgeForm: React.FC<BridgeFormProps> = ({
onClose,
setCurrentView,
Expand Down Expand Up @@ -82,6 +86,9 @@ export const BridgeForm: React.FC<BridgeFormProps> = ({
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<string | null>(null);
Expand Down Expand Up @@ -119,6 +126,25 @@ export const BridgeForm: React.FC<BridgeFormProps> = ({
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
Expand Down Expand Up @@ -165,7 +191,12 @@ export const BridgeForm: React.FC<BridgeFormProps> = ({
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,
Expand Down Expand Up @@ -237,12 +268,25 @@ export const BridgeForm: React.FC<BridgeFormProps> = ({
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,
Expand Down Expand Up @@ -331,22 +375,28 @@ export const BridgeForm: React.FC<BridgeFormProps> = ({
// 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;

Expand Down Expand Up @@ -396,6 +446,7 @@ export const BridgeForm: React.FC<BridgeFormProps> = ({
engine={engine}
timeEstimate={timeEstimate}
isQuoteLoading={quoteLoading}
amountHasError={amountBelowMin}
/>

{fromNetworkName !== toNetworkName && (
Expand All @@ -408,6 +459,18 @@ export const BridgeForm: React.FC<BridgeFormProps> = ({
</div>
)}

{cngnRateUnavailable && (
<div className="rounded-xl border border-amber-100 bg-amber-50 p-3 text-sm text-amber-700 dark:border-amber-900/30 dark:bg-amber-900/20 dark:text-amber-400">
{CNGN_RATE_UNAVAILABLE_MESSAGE}
</div>
)}

{amountBelowMin && convertMinMessage && (
<div className="rounded-xl border border-red-100 bg-red-50 p-3 text-sm text-red-600 dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-400">
{convertMinMessage}
</div>
)}

{noRailAvailable ? (
<div className="rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-900/30 p-3 text-sm text-amber-700 dark:text-amber-400">
No conversion rail is available for this route at this time.
Expand Down
10 changes: 9 additions & 1 deletion app/components/bridge/BridgeRouteSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -83,6 +85,7 @@ export const BridgeRouteSelector: React.FC<BridgeRouteSelectorProps> = ({
onToNetworkChange,
outputAmount,
isQuoteLoading,
amountHasError = false,
}) => {
const { crossChainBalances } = useBalance();
const { allTokens } = useTokens();
Expand Down Expand Up @@ -307,7 +310,12 @@ export const BridgeRouteSelector: React.FC<BridgeRouteSelectorProps> = ({
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 && (
<div className="flex shrink-0 flex-col items-end gap-1">
Expand Down
26 changes: 19 additions & 7 deletions app/hooks/useSwapButton.ts
Original file line number Diff line number Diff line change
@@ -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). */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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).
Expand Down
28 changes: 28 additions & 0 deletions app/lib/marketLiquidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Loading
Loading