Hey is a decentralized and permissionless social media app built with Lens Protocol 馃尶
1import { BASE_RPC_URL } from "@hey/data/constants";
2import type { GetCoinResponse } from "@zoralabs/coins-sdk";
3import {
4 createTradeCall,
5 type TradeParameters,
6 tradeCoin
7} from "@zoralabs/coins-sdk";
8import { useEffect, useMemo, useState } from "react";
9import { toast } from "sonner";
10import type { Address } from "viem";
11import {
12 createPublicClient,
13 erc20Abi,
14 formatEther,
15 formatUnits,
16 http,
17 parseEther,
18 parseUnits
19} from "viem";
20import { base } from "viem/chains";
21import { useAccount, useConfig, useWalletClient } from "wagmi";
22import { getWalletClient } from "wagmi/actions";
23import { Button, Image, Input, Tabs, Tooltip } from "@/components/Shared/UI";
24import useHandleWrongNetwork from "@/hooks/useHandleWrongNetwork";
25
26interface TradeModalProps {
27 coin: NonNullable<GetCoinResponse["zora20Token"]>;
28 onClose: () => void;
29}
30
31type Mode = "buy" | "sell";
32
33const Trade = ({ coin, onClose }: TradeModalProps) => {
34 const { address } = useAccount();
35 const config = useConfig();
36 const { data: walletClient } = useWalletClient({ chainId: base.id });
37 const publicClient = useMemo(
38 () =>
39 createPublicClient({
40 chain: base,
41 transport: http(BASE_RPC_URL, { batch: { batchSize: 30 } })
42 }),
43 []
44 );
45 const handleWrongNetwork = useHandleWrongNetwork();
46
47 const [mode, setMode] = useState<Mode>("buy");
48 const [amount, setAmount] = useState("");
49 const [loading, setLoading] = useState(false);
50 const [ethBalance, setEthBalance] = useState<bigint>(0n);
51 const [tokenBalance, setTokenBalance] = useState<bigint>(0n);
52 const [estimatedOut, setEstimatedOut] = useState<string>("");
53
54 useEffect(() => {
55 (async () => {
56 if (!address) return;
57 try {
58 const [eth, token] = await Promise.all([
59 publicClient.getBalance({ address }),
60 publicClient.readContract({
61 abi: erc20Abi,
62 address: coin.address as Address,
63 args: [address],
64 functionName: "balanceOf"
65 })
66 ]);
67 setEthBalance(eth);
68 setTokenBalance(token as bigint);
69 } catch {}
70 })();
71 }, [address, coin.address, publicClient]);
72
73 const tokenDecimals = 18;
74
75 const setPercentAmount = (pct: number) => {
76 const decimals = 6;
77 if (mode === "buy") {
78 const available = Number(formatEther(ethBalance));
79 const gasReserve = 0.0002;
80 const baseAmt = (available * pct) / 100;
81 const amt = pct === 100 ? Math.max(baseAmt - gasReserve, 0) : baseAmt;
82 setAmount(amt.toFixed(decimals));
83 } else {
84 const available = Number(formatUnits(tokenBalance, tokenDecimals));
85 const amt = Math.max((available * pct) / 100, 0);
86 setAmount(amt.toFixed(decimals));
87 }
88 };
89
90 const makeParams = (address: Address): TradeParameters | null => {
91 if (!amount || Number(amount) <= 0) return null;
92
93 if (mode === "buy") {
94 return {
95 amountIn: parseEther(amount),
96 buy: { address: coin.address as Address, type: "erc20" },
97 sell: { type: "eth" },
98 sender: address,
99 slippage: 0.1
100 };
101 }
102
103 return {
104 amountIn: parseUnits(amount, tokenDecimals),
105 buy: { type: "eth" },
106 sell: { address: coin.address as Address, type: "erc20" },
107 sender: address,
108 slippage: 0.1
109 };
110 };
111
112 const handleSubmit = async () => {
113 if (!address) {
114 return toast.error("Connect a wallet to trade");
115 }
116
117 const params = makeParams(address);
118 if (!params) return;
119
120 try {
121 setLoading(true);
122 await handleWrongNetwork({ chainId: base.id });
123 const client =
124 (await getWalletClient(config, { chainId: base.id })) || walletClient;
125 if (!client) {
126 setLoading(false);
127 return toast.error("Please switch to Base network");
128 }
129
130 await tradeCoin({
131 account: client.account,
132 publicClient,
133 tradeParameters: params,
134 validateTransaction: false,
135 walletClient: client
136 });
137 toast.success("Trade submitted");
138 onClose();
139 } catch {
140 toast.error("Trade failed");
141 } finally {
142 setLoading(false);
143 }
144 };
145
146 useEffect(() => {
147 let cancelled = false;
148 let intervalId: ReturnType<typeof setInterval> | undefined;
149 let timeoutId: ReturnType<typeof setTimeout> | undefined;
150
151 const run = async () => {
152 const sender = (address as Address) || undefined;
153 if (!sender || !amount) {
154 setEstimatedOut("");
155 return;
156 }
157
158 const params: TradeParameters =
159 mode === "buy"
160 ? {
161 amountIn: parseEther(amount),
162 buy: { address: coin.address as Address, type: "erc20" },
163 sell: { type: "eth" },
164 sender,
165 slippage: 0.1
166 }
167 : {
168 amountIn: parseUnits(amount, tokenDecimals),
169 buy: { type: "eth" },
170 sell: { address: coin.address as Address, type: "erc20" },
171 sender,
172 slippage: 0.1
173 };
174
175 try {
176 const q = await createTradeCall(params);
177 if (!cancelled) {
178 const out = q.quote.amountOut || "0";
179 setEstimatedOut(out);
180 }
181 } catch {
182 if (!cancelled) setEstimatedOut("");
183 }
184 };
185
186 timeoutId = setTimeout(() => {
187 void run();
188 }, 300);
189
190 intervalId = setInterval(() => {
191 void run();
192 }, 8000);
193
194 return () => {
195 cancelled = true;
196 if (intervalId) clearInterval(intervalId);
197 if (timeoutId) clearTimeout(timeoutId);
198 };
199 }, [address, amount, coin.address, mode]);
200
201 const symbol = coin.symbol || "";
202
203 const balanceLabel =
204 mode === "buy"
205 ? `Balance: ${Number(formatEther(ethBalance)).toFixed(6)}`
206 : `Balance: ${Number(formatUnits(tokenBalance, tokenDecimals)).toFixed(3)}`;
207
208 return (
209 <div className="p-5">
210 <Tabs
211 active={mode}
212 className="mb-4"
213 layoutId="trade-mode"
214 setActive={(t) => setMode(t as Mode)}
215 tabs={[
216 { name: "Buy", type: "buy" },
217 { name: "Sell", type: "sell" }
218 ]}
219 />
220 <div className="relative mb-2">
221 <Input
222 inputMode="decimal"
223 label="Amount"
224 onChange={(e) => setAmount(e.target.value)}
225 placeholder={mode === "buy" ? "0.01" : "0"}
226 prefix={
227 mode === "buy" ? (
228 "ETH"
229 ) : (
230 <Tooltip content={`$${symbol}`}>
231 <Image
232 alt={coin.name}
233 className="size-5 rounded-full"
234 height={20}
235 src={coin.mediaContent?.previewImage?.small}
236 width={20}
237 />
238 </Tooltip>
239 )
240 }
241 value={amount}
242 />
243 </div>
244 <div className="mb-3 flex items-center justify-between text-gray-500 text-xs dark:text-gray-400">
245 <div>
246 Estimated amount:{" "}
247 {estimatedOut
248 ? mode === "buy"
249 ? `${Number(
250 formatUnits(BigInt(estimatedOut), tokenDecimals)
251 ).toFixed(0)}`
252 : `${Number(formatEther(BigInt(estimatedOut))).toFixed(6)} ETH`
253 : "-"}
254 </div>
255 <div>{balanceLabel}</div>
256 </div>
257 <div className="mb-3 grid grid-cols-4 gap-2">
258 {[25, 50, 75].map((p) => (
259 <Button key={p} onClick={() => setPercentAmount(p)} outline>
260 {p}%
261 </Button>
262 ))}
263 <Button onClick={() => setPercentAmount(100)} outline>
264 Max
265 </Button>
266 </div>
267 <Button
268 className="mt-4 w-full"
269 disabled={!amount || !address}
270 loading={loading}
271 onClick={handleSubmit}
272 size="lg"
273 >
274 {mode === "buy" ? "Buy" : "Sell"}
275 </Button>
276 </div>
277 );
278};
279
280export default Trade;