Skip to content
Open
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
77 changes: 71 additions & 6 deletions src/Coins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { SearchIcon } from "lucide-react";
import { CoinData } from "./hooks/metadata/coin-utils";
import { debounce } from "./lib/utils";
import { useTranslation } from "react-i18next";
import { formatEther } from "viem";
import { formatEther, isAddress } from "viem";
import { useErc20TokenInfo } from "./hooks/use-erc20-token-info";
import { createErc20TokenMeta } from "./lib/coins";

// Page size for pagination
const PAGE_SIZE = 20;
Expand All @@ -28,19 +30,73 @@ export const Coins = () => {
const { coins, allCoins, page, goToNextPage, isLoading, setPage } = usePagedCoins(PAGE_SIZE);

/* ------------------------------------------------------------------
* Search handling
* Search handling - trimmed query
* ------------------------------------------------------------------ */
// 1) memoize the trimmed query
const trimmedQuery = useMemo(() => searchQuery.trim().toLowerCase(), [searchQuery]);

/* ------------------------------------------------------------------
* ERC20 token detection and info fetching
* ------------------------------------------------------------------ */
// Check if the search query is a valid Ethereum address
const isSearchingForAddress = useMemo(() => {
return trimmedQuery.length > 0 && isAddress(trimmedQuery);
}, [trimmedQuery]);

// Fetch ERC20 token info when searching for an address
const { data: erc20TokenInfo, isLoading: isErc20Loading } = useErc20TokenInfo(
isSearchingForAddress ? trimmedQuery : undefined
);

// Convert ERC20 token info to CoinData format for display
const erc20CoinData = useMemo((): CoinData | null => {
if (!erc20TokenInfo) return null;

const tokenMeta = createErc20TokenMeta(
erc20TokenInfo.address,
erc20TokenInfo.symbol,
erc20TokenInfo.decimals,
erc20TokenInfo.name
);

// Convert TokenMeta to CoinData format expected by the display components
return {
coinId: 0n, // Use 0n as bigint for ERC20 tokens
tokenURI: tokenMeta.tokenUri || "",
name: tokenMeta.name,
symbol: tokenMeta.symbol,
description: `ERC20 Token: ${erc20TokenInfo.address}`,
imageUrl: tokenMeta.tokenUri || "",
decimals: erc20TokenInfo.decimals,
poolId: undefined,
reserve0: 0n,
reserve1: 0n,
liquidity: 0n, // Add missing liquidity property
priceInEth: 0,
saleStatus: null,
swapFee: "0",
votes: 0n,
// Add ERC20-specific metadata
isErc20Token: true,
erc20Address: erc20TokenInfo.address,
} as CoinData & { isErc20Token: boolean; erc20Address: string };
}, [erc20TokenInfo]);

/* ------------------------------------------------------------------
* Search handling
* ------------------------------------------------------------------ */

// 2) derive `searchResults` (and "active" flag) purely from inputs
const searchResults = useMemo(() => {
if (!trimmedQuery) return [];

// If searching for an ERC20 address and we have token info, include it
const erc20Results = erc20CoinData ? [erc20CoinData] : [];

// Use the full dataset when it's loaded, fall back to paged data while waiting
const dataToSearch = allCoins && allCoins.length > 0 ? allCoins : coins;

return dataToSearch.filter((coin) => {
const regularResults = dataToSearch.filter((coin) => {
// Split the search query into words for multi-term searching
const searchTerms = trimmedQuery.split(/\s+/).filter((term) => term.length > 0);

Expand Down Expand Up @@ -70,7 +126,10 @@ export const Coins = () => {
);
});
});
}, [trimmedQuery, allCoins, coins]);

// Combine ERC20 results with regular results, ERC20 first
return [...erc20Results, ...regularResults];
}, [trimmedQuery, allCoins, coins, erc20CoinData]);

const isSearchActive = Boolean(trimmedQuery);

Expand Down Expand Up @@ -253,7 +312,13 @@ export const Coins = () => {
* ------------------------------------------------------------------ */
const filterValidCoins = useCallback((coinsList: CoinData[]): CoinData[] => {
return coinsList.filter(
(coin) => coin && coin.coinId !== undefined && coin.coinId !== null && Number(coin.coinId) > 0, // Exclude ID 0 and any negative IDs
(coin) => {
// Allow ERC20 tokens (they have coinId "0" as string and isErc20Token flag)
if ((coin as any).isErc20Token) return true;

// For regular coins, exclude ID 0 and any negative IDs
return coin && coin.coinId !== undefined && coin.coinId !== null && Number(coin.coinId) > 0;
}
);
}, []);

Expand Down Expand Up @@ -337,7 +402,7 @@ export const Coins = () => {
}
onPrev={debouncedPrevPage}
onNext={debouncedNextPage}
isLoading={isLoading || (sortType === "recency" && isChronologicalLoading)}
isLoading={isLoading || (sortType === "recency" && isChronologicalLoading) || (isSearchingForAddress && isErc20Loading)}
currentPage={page + 1}
totalPages={Math.max(
1,
Expand Down
116 changes: 91 additions & 25 deletions src/SwapAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import { useBatchingSupported } from "./hooks/use-batching-supported";
import { CoinsAbi, CoinsAddress } from "./constants/Coins";
import { useReadContract } from "wagmi";
import { cn } from "./lib/utils";
import { useErc20Allowance } from "./hooks/use-erc20-allowance";
import { erc20Abi } from "viem";

export const SwapAction = () => {
const { t } = useTranslation();
Expand Down Expand Up @@ -98,6 +100,27 @@ export const SwapAction = () => {
isPending,
error: writeError,
} = useSendTransaction();

// ERC20 allowance check for sell token when creating limit orders
const {
allowance: sellTokenAllowance,
approveMax: approveSellToken,
isApproving: isApprovingSellToken
} = useErc20Allowance({
token: sellToken?.isErc20Token ? sellToken.erc20Address! : "0x0000000000000000000000000000000000000000",
spender: CookbookAddress,
});

// Check if ERC20 approval is needed for limit orders
const needsErc20Approval = useMemo(() => {
if (swapMode !== "limit" || !sellToken.isErc20Token || !sellAmt) return false;
try {
const sellAmountParsed = parseUnits(sellAmt, sellToken.decimals || 18);
return !sellTokenAllowance || sellTokenAllowance < sellAmountParsed;
} catch {
return false;
}
}, [swapMode, sellToken.isErc20Token, sellAmt, sellToken.decimals, sellTokenAllowance]);
const { sendCalls } = useSendCalls();
const isBatchingSupported = useBatchingSupported();
const { isSuccess } = useWaitForTransactionReceipt({ hash: txHash });
Expand Down Expand Up @@ -476,12 +499,14 @@ export const SwapAction = () => {
Math.floor(Date.now() / 1000) + deadline * 24 * 60 * 60;

// Prepare token addresses and IDs
const tokenInAddress =
sellToken.id === null
const tokenInAddress = sellToken.isErc20Token
? sellToken.erc20Address! // Use actual ERC20 contract address
: sellToken.id === null
? "0x0000000000000000000000000000000000000000"
: sellToken.id < 1000000n ? CookbookAddress : CoinsAddress;
const tokenOutAddress =
buyToken.id === null
const tokenOutAddress = buyToken.isErc20Token
? buyToken.erc20Address! // Use actual ERC20 contract address
: buyToken.id === null
? "0x0000000000000000000000000000000000000000"
: buyToken.id < 1000000n ? CookbookAddress : CoinsAddress;
const idIn = sellToken.id || 0n;
Expand All @@ -503,17 +528,35 @@ export const SwapAction = () => {
value?: bigint;
}> = [];

// For non-ETH, non-cookbook tokens, ensure operator approval first
if (sellToken.id !== null && sellToken.id >= 1000000n && !isOperator) {
const approvalData = encodeFunctionData({
abi: CoinsAbi,
functionName: "setOperator",
args: [CookbookAddress, true],
});
calls.push({
to: CoinsAddress,
data: approvalData,
});
// For non-ETH tokens, ensure proper approval first
if (sellToken.id !== null) {
if (sellToken.isErc20Token) {
// For ERC20 tokens, check if we need ERC20 approval
const sellAmountParsed = parseUnits(sellAmt, sellToken.decimals || 18);
if (!sellTokenAllowance || sellTokenAllowance < sellAmountParsed) {
// Need ERC20 approval first - use max approval for better UX
const approvalData = encodeFunctionData({
abi: erc20Abi,
functionName: "approve",
args: [CookbookAddress, 2n ** 256n - 1n], // max uint256
});
calls.push({
to: sellToken.erc20Address!,
data: approvalData,
});
}
} else if (sellToken.id >= 1000000n && !isOperator) {
// For regular coins, ensure operator approval
const approvalData = encodeFunctionData({
abi: CoinsAbi,
functionName: "setOperator",
args: [CookbookAddress, true],
});
calls.push({
to: CoinsAddress,
data: approvalData,
});
}
}

// Encode the makeOrder function call
Expand Down Expand Up @@ -844,15 +887,37 @@ export const SwapAction = () => {
)}

{/* ACTION BUTTON */}
<button
onClick={swapMode === "instant" ? executeSwap : createOrder}
disabled={
!isConnected ||
!sellAmt ||
isPending ||
(swapMode === "instant" && !canSwap) ||
(swapMode === "limit" && (!buyAmt || !buyToken))
}
{needsErc20Approval ? (
<button
onClick={approveSellToken}
disabled={!isConnected || isApprovingSellToken}
className={`mt-2 button text-base px-8 py-4 bg-orange-500 hover:bg-orange-600 text-white font-bold rounded-lg transform transition-all duration-200
${
!isConnected || isApprovingSellToken
? "opacity-50 cursor-not-allowed"
: "opacity-100 hover:scale-105 hover:shadow-lg focus:ring-4 focus:ring-orange-500/50 focus:outline-none"
}
`}
>
{isApprovingSellToken ? (
<span className="flex items-center gap-2">
<LoadingLogo className="m-0 p-0 h-6 w-6" size="sm" />
{t("common.approving")}
</span>
) : (
`${t("common.approve")} ${sellToken.symbol}`
)}
</button>
) : (
<button
onClick={swapMode === "instant" ? executeSwap : createOrder}
disabled={
!isConnected ||
!sellAmt ||
isPending ||
(swapMode === "instant" && !canSwap) ||
(swapMode === "limit" && (!buyAmt || !buyToken))
}
className={`mt-2 button text-base px-8 py-4 bg-primary text-primary-foreground font-bold rounded-lg transform transition-all duration-200
${
!isConnected ||
Expand All @@ -875,7 +940,8 @@ export const SwapAction = () => {
) : (
t("common.create_order")
)}
</button>
</button>
)}

{/* Status and error messages */}
{/* Show transaction statuses */}
Expand Down
70 changes: 66 additions & 4 deletions src/components/OrderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { CoinsAbi, CoinsAddress } from "@/constants/Coins";
import { mainnet } from "viem/chains";
import { handleWalletError } from "@/lib/errors";
import { useOperatorStatus } from "@/hooks/use-operator-status";
import { useErc20TokenInfo } from "@/hooks/use-erc20-token-info";
import { createErc20TokenMeta } from "@/lib/coins";

interface OrderCardProps {
order: Order;
Expand Down Expand Up @@ -55,14 +57,74 @@ export const OrderCard = ({
const publicClient = usePublicClient({
chainId: mainnet.id,
});

// Parse order data
const tokenInId = order.idIn === "0" ? null : BigInt(order.idIn);
const tokenOutId = order.idOut === "0" ? null : BigInt(order.idOut);

const tokenIn =
tokenInId === null ? ETH_TOKEN : tokens.find((t) => t.id === tokenInId);
const tokenOut =
tokenOutId === null ? ETH_TOKEN : tokens.find((t) => t.id === tokenOutId);
// Check if we need to fetch ERC20 token info for unknown tokens
const isTokenInUnknownErc20 = (tokenInId === null || tokenInId === 0n) &&
order.tokenIn !== "0x0000000000000000000000000000000000000000" &&
!tokens.find((t) => t.isErc20Token && t.erc20Address?.toLowerCase() === order.tokenIn.toLowerCase());

const isTokenOutUnknownErc20 = (tokenOutId === null || tokenOutId === 0n) &&
order.tokenOut !== "0x0000000000000000000000000000000000000000" &&
!tokens.find((t) => t.isErc20Token && t.erc20Address?.toLowerCase() === order.tokenOut.toLowerCase());

// Fetch ERC20 token info for unknown tokens
const { data: tokenInErc20Info } = useErc20TokenInfo(isTokenInUnknownErc20 ? order.tokenIn : undefined);
const { data: tokenOutErc20Info } = useErc20TokenInfo(isTokenOutUnknownErc20 ? order.tokenOut : undefined);

// Helper function to resolve tokens - handles both regular tokens and ERC20 tokens
const resolveToken = (tokenAddress: string, tokenId: bigint | null, erc20Info?: any) => {
// ETH case (zero address)
if (tokenAddress === "0x0000000000000000000000000000000000000000") {
return ETH_TOKEN;
}

// For id0 tokens, this could be an ERC20 token - check by address
if (tokenId === null || tokenId === 0n) {
// First try to find by ERC20 address in existing tokens
const erc20Token = tokens.find((t) => t.isErc20Token && t.erc20Address?.toLowerCase() === tokenAddress.toLowerCase());
if (erc20Token) {
return erc20Token;
}

// If not found and we have ERC20 info, create a temporary TokenMeta
if (erc20Info) {
return createErc20TokenMeta(
erc20Info.address,
erc20Info.symbol,
erc20Info.decimals,
erc20Info.name
);
}

// If not found as ERC20, return ETH_TOKEN for null ID
if (tokenId === null) {
return ETH_TOKEN;
}

// For id0 non-ERC20 tokens, fall through to regular lookup
}

// Regular token lookup by ID
return tokens.find((t) => t.id === tokenId);
};

const tokenIn = resolveToken(order.tokenIn, tokenInId, tokenInErc20Info);
const tokenOut = resolveToken(order.tokenOut, tokenOutId, tokenOutErc20Info);

// Early return if we can't resolve tokens
if (!tokenIn || !tokenOut) {
return (
<Card className="p-4">
<div className="text-center text-muted-foreground">
{t("orders.loading_token_info")}
</div>
</Card>
);
}

const amtIn = BigInt(order.amtIn);
const amtOut = BigInt(order.amtOut);
Expand Down
Loading