diff --git a/src/app/history/order/[orderHash]/page.tsx b/src/app/history/order/[orderHash]/page.tsx index 65679a4..502ff0e 100644 --- a/src/app/history/order/[orderHash]/page.tsx +++ b/src/app/history/order/[orderHash]/page.tsx @@ -17,6 +17,7 @@ import { Spinner } from "#/components/Spinner"; import { TokenLogo } from "#/components/TokenLogo"; import { CowOrder, useOrder } from "#/contexts/ordersContext"; import { ChainId } from "#/lib/publicClients"; +import { formatTimeDelta } from "#/lib/timeDelta"; import { buildBlockExplorerTxUrl, buildOrderCowExplorerUrl, @@ -57,6 +58,9 @@ export default function OrderPage({ const orderDateTime = formatDateTime( epochToDate(Number(stopLossOrder?.blockTimestamp)) ); + const orderWaitTime = formatTimeDelta( + stopLossOrder?.stopLossData?.validityBucketSeconds as number + ); const amountIn = Number(stopLossOrder?.stopLossData?.tokenAmountIn) / @@ -120,6 +124,12 @@ export default function OrderPage({ > {orderDateTime} + + {orderWaitTime} +
diff --git a/src/components/RootLayout.tsx b/src/components/RootLayout.tsx index b818255..a2febce 100644 --- a/src/components/RootLayout.tsx +++ b/src/components/RootLayout.tsx @@ -1,7 +1,7 @@ "use client"; import { Button, Toaster } from "@bleu-fi/ui"; -import { ClockIcon } from "@radix-ui/react-icons"; +import { ClockIcon, PlusIcon } from "@radix-ui/react-icons"; import SafeProvider from "@safe-global/safe-apps-react-sdk"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -26,6 +26,19 @@ function HistoryButton() { ); } +function BuilderButton() { + return ( + + + + ); +} + export function RootLayout({ children }: React.PropsWithChildren) { const path = usePathname(); return ( @@ -34,7 +47,7 @@ export function RootLayout({ children }: React.PropsWithChildren) {
- {path !== "/history" && } + {path === "/history" ? : }
{children}
{ + setImageSrc(cowprotocolTokenLogoUrl(tokenAddress, chainId) || FALLBACK_SRC); + }, [tokenAddress, chainId]); + return ( token.chainId === chainId @@ -51,6 +54,24 @@ export function TokenSelect({ setOpen(false); } + async function handleImportToken() { + try { + const importedToken = await fetchTokenInfo( + search as Address, + chainId as ChainId + ); + handleSelectToken(importedToken); + toast({ + title: "Token imported", + }); + } catch (e) { + toast({ + title: "Error importing token", + variant: "destructive", + }); + } + } + return ( <> @@ -87,6 +108,10 @@ export function TokenSelect({ { + setSearch(search); + if (value === "import") { + return Number(isAddress(search)); + } if (!search) return 1; const regex = new RegExp(search, "i"); return Number(regex.test(value)); @@ -95,7 +120,7 @@ export function TokenSelect({ > - No tokens found + No results found {tokens.map((token) => ( ))} + + Import token + diff --git a/src/components/edges/AddHookEdge.tsx b/src/components/edges/AddHookEdge.tsx index 97b56e6..5eb7c87 100644 --- a/src/components/edges/AddHookEdge.tsx +++ b/src/components/edges/AddHookEdge.tsx @@ -28,22 +28,22 @@ export const HOOK_OPTIONS = [ label: "Mint BAL from gauges", value: "hookMintBal", }, - { - label: "Multisend", - value: "hookMultiSend", - }, - { - label: "Aave withdraw", - value: "hookAaveWithdraw", - }, - { - label: "Claim vesting", - value: "hookClaimVesting", - }, - { - label: "Exit pool", - value: "hookExitPool", - }, + // { + // label: "Multisend", + // value: "hookMultiSend", + // }, + // { + // label: "Aave withdraw", + // value: "hookAaveWithdraw", + // }, + // { + // label: "Claim vesting", + // value: "hookClaimVesting", + // }, + // { + // label: "Exit pool", + // value: "hookExitPool", + // }, ]; export function AddHookEdge({ diff --git a/src/components/menus/SwapMenu.tsx b/src/components/menus/SwapMenu.tsx index 947774e..0767cd2 100644 --- a/src/components/menus/SwapMenu.tsx +++ b/src/components/menus/SwapMenu.tsx @@ -101,34 +101,35 @@ export function SwapMenu({ type="number" step={1 / 10 ** amountDecimals} /> - {walletAmount != "0" && ( -
+ +
+ - - Wallet Balance:{" "} - {formatNumber( - walletAmount, - 4, - "decimal", - "standard", - 0.0001 - )} - + Wallet Balance:{" "} + {walletAmount == "0" + ? walletAmount + : formatNumber( + walletAmount, + 4, + "decimal", + "standard", + 0.0001 + )} - -
- )} + + +
Nodes menu

Select a node to see the menu and edit the parameters

+
+

If you want to delete one order node, select it and press delete

); } diff --git a/src/components/nodes/StopLossNode.tsx b/src/components/nodes/StopLossNode.tsx index 016d027..60004b8 100644 --- a/src/components/nodes/StopLossNode.tsx +++ b/src/components/nodes/StopLossNode.tsx @@ -1,5 +1,6 @@ import { formatNumber } from "@bleu-fi/ui"; +import { useBuilder } from "#/contexts/builder"; import { IStopLossConditionData } from "#/lib/types"; import { InfoTooltip } from "../Tooltip"; @@ -17,6 +18,9 @@ export function StopLossNode({ selected: boolean; data: IStopLossConditionData; }) { + const { getOrderDataByOrderId } = useBuilder(); + const recipeData = getOrderDataByOrderId(data.orderId); + return (
@@ -24,13 +28,18 @@ export function StopLossNode({ Stop Loss Condition - + {data.error && ( - + )}
- If the sell token price falls bellow{" "} + If the {recipeData?.tokenSell.symbol}/{recipeData?.tokenBuy.symbol}{" "} + falls bellow{" "} {formatNumber(data.strikePrice, 4, "decimal", "standard", 0.0001)} diff --git a/src/components/nodes/SubmitNode.tsx b/src/components/nodes/SubmitNode.tsx index 068167f..a133f57 100644 --- a/src/components/nodes/SubmitNode.tsx +++ b/src/components/nodes/SubmitNode.tsx @@ -1,5 +1,5 @@ import { Button, useToast } from "@bleu-fi/ui"; -import { EnterIcon } from "@radix-ui/react-icons"; +import { PaperPlaneIcon } from "@radix-ui/react-icons"; import { useSafeAppsSDK } from "@safe-global/safe-apps-react-sdk"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -146,7 +146,7 @@ export function SubmitButton({ onClick={onClick} >
- +

{isSubmitting ? "Creating Tx..." : "Submit Orders"}

diff --git a/src/components/nodes/SwapNode.tsx b/src/components/nodes/SwapNode.tsx index c92f934..5662790 100644 --- a/src/components/nodes/SwapNode.tsx +++ b/src/components/nodes/SwapNode.tsx @@ -1,4 +1,5 @@ import { formatNumber } from "@bleu-fi/ui"; +import { useEffect, useState } from "react"; import { formatUnits } from "viem"; import { useBuilder } from "#/contexts/builder"; @@ -17,16 +18,28 @@ export function SwapNode({ data: ISwapData; }) { const { fetchBalance } = useSafeBalances(); + const [sellTokenWalletAmount, setSellTokenWalletAmount] = useState(); + const [sellAmount, setSellAmount] = useState(); + const [buyAmount, setBuyAmount] = useState(); const { getOrderDataByOrderId } = useBuilder(); const recipeData = getOrderDataByOrderId(data.orderId); - if (!recipeData) return null; - const sellTokenWalletAmount = Number( - formatUnits( - BigInt(fetchBalance(data.tokenSell.address)), - data.tokenSell.decimals - ) - ); - const [sellAmount, buyAmount] = calculateAmounts(recipeData); + + useEffect(() => { + if (!recipeData) return; + const newSellTokenWalletAmount = Number( + formatUnits( + BigInt(fetchBalance(data.tokenSell.address)), + data.tokenSell.decimals + ) + ); + setSellTokenWalletAmount(newSellTokenWalletAmount); + const [newSellAmount, newBuyAmount] = calculateAmounts(recipeData); + setSellAmount(newSellAmount); + setBuyAmount(newBuyAmount); + }, [recipeData, data, fetchBalance]); + + if (!sellAmount || !buyAmount) return null; + const sellAmountWithSymbol = `${formatNumber(sellAmount, 2, "decimal", "compact", 0.01)} ${data.tokenSell.symbol}`; const buyAmountWithSymbol = `${formatNumber(buyAmount, 2, "decimal", "compact", 0.01)} ${data.tokenBuy.symbol}`; return ( @@ -34,10 +47,13 @@ export function SwapNode({
Swap - {sellTokenWalletAmount < sellAmount && ( - + {(sellTokenWalletAmount || 0) < sellAmount && ( + )} -
{" "} +
{data.isSellOrder ? `Sell ${sellAmountWithSymbol} for at least ${buyAmountWithSymbol}` diff --git a/src/lib/fetchTokenInfo.ts b/src/lib/fetchTokenInfo.ts new file mode 100644 index 0000000..5282c66 --- /dev/null +++ b/src/lib/fetchTokenInfo.ts @@ -0,0 +1,27 @@ +import { Address, erc20Abi, isAddress } from "viem"; +import { ChainId, publicClientsFromIds } from "./publicClients"; +import { IToken } from "./types"; + +export async function fetchTokenInfo( + tokenAddress: Address, + chainId: ChainId +): Promise { + const publicClient = publicClientsFromIds[chainId]; + const [symbol, decimals] = await Promise.all([ + publicClient.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: "symbol", + }), + publicClient.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: "decimals", + }), + ]); + return { + address: tokenAddress as Address, + decimals, + symbol, + }; +} diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 77d2662..9fae97d 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -1,5 +1,7 @@ import { Address, isAddress } from "viem"; import { z } from "zod"; +import { normalize } from "viem/ens"; + import { IToken, TIME_OPTIONS } from "./types"; import { ChainId, publicClientsFromIds } from "./publicClients"; import { fetchCowQuote } from "./cowApi/fetchCowQuote"; @@ -20,6 +22,22 @@ const basicTokenSchema = z.object({ symbol: z.string(), }); +const ensSchema = z + .string() + .min(1) + .refine((value) => value.includes(".eth"), { + message: "Provided address is invalid", + }) + .transform(async (value) => { + const publicClient = publicClientsFromIds[1]; + return (await publicClient.getEnsAddress({ + name: normalize(value), + })) as Address; + }) + .refine((value) => isAddress(value), { + message: "Provided address is invalid", + }); + const generateOracleSchema = ({ chainId }: { chainId: ChainId }) => { const publicClient = publicClientsFromIds[chainId]; return basicAddressSchema.refine( @@ -51,27 +69,17 @@ export const stopLossConditionSchema = z message: "Tokens sell and buy must be different", }); -export const swapSchema = z - .object({ - tokenSell: basicTokenSchema, - tokenBuy: basicTokenSchema, - amount: z.coerce.number().positive(), - allowedSlippage: z.coerce.number().positive(), - receiver: basicAddressSchema, - isPartiallyFillable: z.coerce.boolean(), - validFrom: z.coerce.string(), - isSellOrder: z.coerce.boolean(), - validityBucketTime: z.nativeEnum(TIME_OPTIONS), - }) - .refine( - (data) => { - return data.tokenSell.address != data.tokenBuy.address; - }, - { - path: ["tokenBuy"], - message: "Tokens sell and buy must be different", - } - ); +export const swapSchema = z.object({ + tokenSell: basicTokenSchema, + tokenBuy: basicTokenSchema, + amount: z.coerce.number().positive(), + allowedSlippage: z.coerce.number().positive(), + receiver: z.union([basicAddressSchema, ensSchema]), + isPartiallyFillable: z.coerce.boolean(), + validFrom: z.coerce.string(), + isSellOrder: z.coerce.boolean(), + validityBucketTime: z.nativeEnum(TIME_OPTIONS), +}); export const generateStopLossRecipeSchema = ({ chainId, @@ -83,7 +91,7 @@ export const generateStopLossRecipeSchema = ({ tokenSell: basicTokenSchema, tokenBuy: basicTokenSchema, amount: z.coerce.number().positive(), - allowedSlippage: z.coerce.number().positive(), + allowedSlippage: z.coerce.number().nonnegative().max(100), receiver: basicAddressSchema, isPartiallyFillable: z.coerce.boolean(), validFrom: z.coerce.string(), @@ -94,6 +102,15 @@ export const generateStopLossRecipeSchema = ({ tokenBuyOracle: basicAddressSchema, maxTimeSinceLastOracleUpdate: z.nativeEnum(TIME_OPTIONS), }) + .refine( + (data) => { + return data.tokenSell.address != data.tokenBuy.address; + }, + { + path: ["tokenBuy"], + message: "Tokens sell and buy must be different", + } + ) .superRefine((data, ctx) => { const oracleRouter = new CHAINS_ORACLE_ROUTER_FACTORY[chainId as ChainId]( { @@ -138,7 +155,7 @@ export const generateStopLossRecipeSchema = ({ if (res.errorType) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: capitalize(res.description), + message: `${res.errorType}: ${capitalize(res.description)}`, }); } }); diff --git a/src/lib/timeDelta.ts b/src/lib/timeDelta.ts new file mode 100644 index 0000000..0c41e92 --- /dev/null +++ b/src/lib/timeDelta.ts @@ -0,0 +1,30 @@ +export function formatTimeDelta(totalSeconds: number): string { + const secondsPerMinute = 60; + const secondsPerHour = secondsPerMinute * 60; + const secondsPerDay = secondsPerHour * 24; + const secondsPerYear = secondsPerDay * 365; + + let remainingSeconds = totalSeconds; + + const years = Math.floor(remainingSeconds / secondsPerYear); + remainingSeconds %= secondsPerYear; + + const days = Math.floor(remainingSeconds / secondsPerDay); + remainingSeconds %= secondsPerDay; + + const hours = Math.floor(remainingSeconds / secondsPerHour); + remainingSeconds %= secondsPerHour; + + const minutes = Math.floor(remainingSeconds / secondsPerMinute); + remainingSeconds %= secondsPerMinute; + + const parts: string[] = []; + if (years > 0) parts.push(`${years} year${years > 1 ? "s" : ""}`); + if (days > 0) parts.push(`${days} day${days > 1 ? "s" : ""}`); + if (hours > 0) parts.push(`${hours} hour${hours > 1 ? "s" : ""}`); + if (minutes > 0) parts.push(`${minutes} minute${minutes > 1 ? "s" : ""}`); + if (remainingSeconds > 0) + parts.push(`${remainingSeconds} second${remainingSeconds > 1 ? "s" : ""}`); + + return parts.join(", "); +}