diff --git a/.env.example b/.env.example index 41ea2fef..871c9d7d 100644 --- a/.env.example +++ b/.env.example @@ -21,7 +21,7 @@ NEXT_PUBLIC_MARKETS='{"137":[["WMATIC","USDT"],["WETH","USDC"],["USDC","USDT"],[ NEXT_PUBLIC_MANGROVE_CONFIG='{"tokens":{"USDC.T/MGV":{"cashness":1100,"displayedAsPriceDecimals":4,"displayedDecimals":2},"USDT.T/MGV":{"cashness":1101,"displayedAsPriceDecimals":4,"displayedDecimals":2},"WETH.T/MGV":{"cashness":100,"displayedAsPriceDecimals":6,"displayedDecimals":4},"WMATIC.T/MGV":{"cashness":10,"displayedAsPriceDecimals":6,"displayedDecimals":4},"WBTC.T/AAVEv3":{"cashness":200,"displayedAsPriceDecimals":6,"displayedDecimals":4},"DAI.T/AAVEv3":{"cashness":1000,"displayedAsPriceDecimals":6,"displayedDecimals":4},"CRV.T/AAVEv3":{"cashness":50,"displayedAsPriceDecimals":6,"displayedDecimals":4}}}' NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_HOST=https://eu.posthog.com -NEXT_PUBLIC_LEADERBOARD_API= +NEXT_PUBLIC_MANGROVE_DATA_API_HOST= NEXT_PUBLIC_DOMAIN_ADDRESS= NEXT_PUBLIC_REFERRAL_SERVER_URL= NEXT_PUBLIC_BLAST_DATA_SERVICE= diff --git a/app/api/addresses/route.ts b/app/api/addresses/route.ts index 3b853731..d2539f19 100644 --- a/app/api/addresses/route.ts +++ b/app/api/addresses/route.ts @@ -6,7 +6,7 @@ export function GET() { if (mangroveConfig) { configuration.updateConfiguration(mangroveConfig) } - const allAddresses = configuration.addresses.getAllAddressesForAllNetworks(); + const allAddresses = configuration.addresses.getAllAddressesForAllNetworks() return Response.json(allAddresses) } diff --git a/app/faucet/layout.tsx b/app/faucet/layout.tsx index bb458ad7..b53cc7c0 100644 --- a/app/faucet/layout.tsx +++ b/app/faucet/layout.tsx @@ -1,6 +1,12 @@ import React from "react" import { Navbar } from "@/components/navbar" +import { Metadata } from "next" + +export const metadata: Metadata = { + title: "Faucet | Mangrove DEX", + description: "Faucet on Mangrove DEX", +} export default function Layout({ children }: React.PropsWithChildren) { return ( diff --git a/app/points/_components/current-boost.tsx b/app/points/_components/current-boost.tsx index c8f13c5d..4b7b493a 100644 --- a/app/points/_components/current-boost.tsx +++ b/app/points/_components/current-boost.tsx @@ -1,20 +1,33 @@ +import InfoTooltip from "@/components/info-tooltip" import { cn } from "@/utils" +import { getLevels } from "../constants" import { formatNumber } from "../utils" import BoxContainer from "./box-container" type Props = { className?: string level?: number + volume?: number + type?: string boost?: number - previousVolume?: number +} + +function formatNFTName(name: string): string { + return name + .replace(/(?!^)([A-Z][a-z])/g, " $1") // Insert a space before each uppercase letter that is not at the start of the string and is not preceded by another uppercase letter + .trim() // Remove any leading or trailing spaces } export default function CurrentBoost({ className, - level = 0, + // level = 0, boost = 1, - previousVolume = 0, + volume = 0, + type, }: Props) { + const { nextIndex, currentIndex } = getLevels(volume) + const level = currentIndex + console.log("nextIndex", nextIndex, currentIndex) return (
@@ -43,7 +56,7 @@ export default function CurrentBoost({
Current boost
-
+
- Level {level} + Level {nextIndex} + {type?.includes("NFT") && ( + + {formatNFTName(type)} + + You've received a {boost}x boost for holding the{" "} + {formatNFTName(type)} + + + )}
- previous volume {formatNumber(previousVolume)} + previous volume {formatNumber(volume)}
diff --git a/app/points/_components/join-program-banner.tsx b/app/points/_components/join-program-banner.tsx index afe30caa..e59288b4 100644 --- a/app/points/_components/join-program-banner.tsx +++ b/app/points/_components/join-program-banner.tsx @@ -3,16 +3,11 @@ import { Text } from "@/components/typography/text" import { Title } from "@/components/typography/title" import { Button } from "@/components/ui/button" import Link from "next/link" -import { useUserRank } from "./leaderboard/use-leaderboard" - -const BULLETS = [ - "hold $100 equivalent in trading or liquidity provision", - "participation in testnet (hold a Mangrove NFT) or beta mainnet", -] +import { useUserPoints } from "./leaderboard/use-leaderboard" export function JoinProgramBanner() { - const { data } = useUserRank() - const points = data?.[0]?.total_points ?? 0 + const { data } = useUserPoints() + const points = data?.total_points ?? 0 if (Number(points) >= 100) return null @@ -22,15 +17,33 @@ export function JoinProgramBanner() {
Join MS1 Points Program! - Become eligible by making the equivalent of $100 in trading volume - (market orders) or generated volume (limit orders). + Start trading now on Mangrove in order to accumulate boosts and + points. You can trade by using market orders, limit orders, + amplified orders and more to come. - - + + - +
+ {shortenedAddress} {userAddress === address ? ( - + you ) : undefined} -
+ ) } diff --git a/app/points/_components/leaderboard/schema.tsx b/app/points/_components/leaderboard/schema.tsx deleted file mode 100644 index a856785e..00000000 --- a/app/points/_components/leaderboard/schema.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { z } from "zod" - -const leaderboardSchema = z.object({ - weightFromBlock: z.number(), - account: z.string(), - taker_points: z.string(), - maker_points: z.string(), - total_points: z.string(), - referees_points: z.string(), - rank: z.number(), -}) - -export type Leaderboard = z.infer - -export function parseLeaderboard(data: unknown[]): Leaderboard[] { - return data - .map((item) => { - try { - return leaderboardSchema.parse(item) - } catch (error) { - console.error("Invalid format for leaderboard: ", item, error) - return null - } - }) - .filter(Boolean) as Leaderboard[] -} diff --git a/app/points/_components/leaderboard/table.tsx b/app/points/_components/leaderboard/table.tsx index 0da34ef8..c3478c44 100644 --- a/app/points/_components/leaderboard/table.tsx +++ b/app/points/_components/leaderboard/table.tsx @@ -3,7 +3,7 @@ import React from "react" import { Title } from "@/components/typography/title" import { DataTable } from "@/components/ui/data-table/data-table" -import { useLeaderboard, useUserRank } from "./use-leaderboard" +import { useLeaderboard, useUserPoints } from "./use-leaderboard" import { useTable } from "./use-table" export function Leaderboard() { @@ -14,26 +14,50 @@ export function Leaderboard() { const leaderboardQuery = useLeaderboard({ filters: { skip: (page - 1) * pageSize, + first: pageSize, }, }) - const useUserRankQuery = useUserRank() - const currentuser = useUserRankQuery.data - const data = React.useMemo( - () => [...(currentuser ?? []), ...(leaderboardQuery.data ?? [])], - [useUserRankQuery.dataUpdatedAt, leaderboardQuery.dataUpdatedAt], - ) + const userPointsQuery = useUserPoints() + const currentUser = userPointsQuery.data + const data = React.useMemo(() => { + if (leaderboardQuery.isLoading) return [] + return leaderboardQuery.data?.leaderboard ?? [] + }, [leaderboardQuery.dataUpdatedAt]) + + const userData = React.useMemo(() => { + if (userPointsQuery.isLoading) return [] + return [userPointsQuery.data] ?? [] + }, [userPointsQuery.dataUpdatedAt]) + + const userTable = useTable({ + //@ts-ignore + data: userData, + }) const table = useTable({ - data, + //@ts-ignore + data: data, }) return (
- + <Title variant={"title1"} className="mb-5"> + Your points + + row.account === currentUser?.account} + /> + Leaderboard row.account === currentuser?.[0]?.account} />
) diff --git a/app/points/_components/leaderboard/use-leaderboard.ts b/app/points/_components/leaderboard/use-leaderboard.ts index 264c93f3..edee93c2 100644 --- a/app/points/_components/leaderboard/use-leaderboard.ts +++ b/app/points/_components/leaderboard/use-leaderboard.ts @@ -1,29 +1,29 @@ "use client" -import { useQuery } from "@tanstack/react-query" +import { keepPreviousData, useQuery } from "@tanstack/react-query" +import { useAccount } from "wagmi" import { getErrorMessage } from "@/utils/errors" -import { useAccount } from "wagmi" -import { parseLeaderboard, type Leaderboard } from "./schema" +import { parseBoosts } from "../../schemas/boosts" +import { parseLeaderboard } from "../../schemas/leaderboard" +import { parsePoints } from "../../schemas/points" -type Params = { +type Params = { filters?: { first?: number skip?: number } - select?: (data: Leaderboard[]) => T } -export function useLeaderboard({ +export function useLeaderboard({ filters: { first = 100, skip = 0 } = {}, - select, -}: Params = {}) { +}: Params = {}) { return useQuery({ queryKey: ["leaderboard", first, skip], queryFn: async () => { try { const res = await fetch( - `${process.env.NEXT_PUBLIC_LEADERBOARD_API}/incentives/leaderboard`, + `${process.env.NEXT_PUBLIC_MANGROVE_DATA_API_HOST}/incentives/leaderboard?offset=${skip}&limit=${first}`, ) const leaderboard = await res.json() return parseLeaderboard(leaderboard) @@ -32,27 +32,27 @@ export function useLeaderboard({ throw new Error() } }, - select, meta: { error: "Unable to retrieve leaderboard", }, retry: false, staleTime: 1 * 60 * 1000, // 1 minute + placeholderData: keepPreviousData, }) } -export function useUserRank() { +export function useUserPoints() { const { address } = useAccount() return useQuery({ - queryKey: ["user-leaderboard", address], + queryKey: ["user-points", address], queryFn: async () => { try { - if (!address) return [] + if (!address) return null const res = await fetch( - `${process.env.NEXT_PUBLIC_LEADERBOARD_API}/incentives/points/${address}`, // TODO: unmock with user address + `${process.env.NEXT_PUBLIC_MANGROVE_DATA_API_HOST}/incentives/points/${address}`, ) - const leaderboard = await res.json() - return parseLeaderboard(leaderboard) + const points = await res.json() + return parsePoints(points) } catch (e) { console.error(getErrorMessage(e)) throw new Error() @@ -66,3 +66,29 @@ export function useUserRank() { staleTime: 1 * 60 * 1000, // 1 minute }) } + +export function useUserBoosts() { + const { address } = useAccount() + return useQuery({ + queryKey: ["user-boosts", address], + queryFn: async () => { + try { + if (!address) return null + const res = await fetch( + `${process.env.NEXT_PUBLIC_MANGROVE_DATA_API_HOST}/incentives/boosts/${address}`, + ) + const boosts = await res.json() + return parseBoosts(boosts) + } catch (e) { + console.error(getErrorMessage(e)) + throw new Error() + } + }, + meta: { + error: "Unable to retrieve user boosts data", + }, + enabled: !!address, + retry: false, + staleTime: 1 * 60 * 1000, // 1 minute + }) +} diff --git a/app/points/_components/leaderboard/use-table.tsx b/app/points/_components/leaderboard/use-table.tsx index 90c1347a..f20564c0 100644 --- a/app/points/_components/leaderboard/use-table.tsx +++ b/app/points/_components/leaderboard/use-table.tsx @@ -10,14 +10,15 @@ import { import React from "react" import { Rank1Icon, Rank2Icon, Rank3Icon } from "@/svgs" +import { formatNumber } from "@/utils/numbers" +import { LeaderboardEntry } from "../../schemas/leaderboard" import Address from "./address" -import type { Leaderboard } from "./schema" -const columnHelper = createColumnHelper() -const DEFAULT_DATA: Leaderboard[] = [] +const columnHelper = createColumnHelper() +const DEFAULT_DATA: LeaderboardEntry[] = [] type Params = { - data?: Leaderboard[] + data?: LeaderboardEntry[] } export function useTable({ data }: Params) { @@ -26,9 +27,9 @@ export function useTable({ data }: Params) { columnHelper.accessor("rank", { header: "Rank", cell: (row) => { - const rank = row.getValue() + const rank = row?.getValue() return ( -
+
{rank > 0 ? rank : undefined}{" "} {rank === 1 ? ( @@ -50,16 +51,22 @@ export function useTable({ data }: Params) { return
}, }), - columnHelper.display({ + columnHelper.accessor("boost", { header: "Boost", - cell: () =>
1x
, + cell: (row) => { + const boost = row.getValue() + if (boost === 0) return
1x
+ return
{boost}x
+ }, }), columnHelper.accessor("maker_points", { header: () =>
LP points
, cell: (row) => { const makerPoints = row.getValue() return ( -
{makerPoints}
+
+ {formatNumber(makerPoints)} +
) }, }), @@ -68,8 +75,19 @@ export function useTable({ data }: Params) { cell: (row) => { const tradingPoints = row.getValue() return ( -
- {tradingPoints} +
+ {formatNumber(tradingPoints)} +
+ ) + }, + }), + columnHelper.accessor("community_points", { + header: () =>
Community points
, + cell: (row) => { + const communityPoints = row.getValue() + return ( +
+ {formatNumber(communityPoints)}
) }, @@ -79,8 +97,8 @@ export function useTable({ data }: Params) { cell: (row) => { const refereesPoints = row.getValue() return ( -
- {refereesPoints} +
+ {formatNumber(refereesPoints)}
) }, @@ -90,7 +108,9 @@ export function useTable({ data }: Params) { cell: (row) => { const totalPoints = row.getValue() return ( -
{totalPoints}
+
+ {formatNumber(totalPoints)} +
) }, }), diff --git a/app/points/_components/next-level.tsx b/app/points/_components/next-level.tsx index aa7d0007..f075d0ea 100644 --- a/app/points/_components/next-level.tsx +++ b/app/points/_components/next-level.tsx @@ -2,7 +2,7 @@ import InfoTooltip from "@/components/info-tooltip" import { Text } from "@/components/typography/text" import { Title } from "@/components/typography/title" import { cn } from "@/utils" -import { LEVELS } from "../constants" +import { LEVELS, getLevels } from "../constants" import { formatNumber } from "../utils" import Animals from "./animals" import BoxContainer from "./box-container" @@ -11,22 +11,21 @@ type Props = { className?: string volume?: number nextRankingDate?: Date + boost?: number } -export default function NextLevel({ +export default function NextLevelVolume({ className, - volume = 10, + volume, // nextRankingDate = new Date("2024-03-04T23:59:59.999Z"), }: Props) { const disabled = !volume - // if returns -1 it means that the volume is higher to the max level - const currentIndex = LEVELS.findIndex((l) => l.amount > volume) - const currentLevel = currentIndex < 0 ? LEVELS.length - 1 : currentIndex + const { nextIndex, nextLevel } = getLevels(volume) - const nextLevel = LEVELS[currentLevel] - const amountToReachNextLevel = (nextLevel?.amount ?? 0) - volume - const hasReachedMaxLevel = currentIndex === -1 + const amountToReachNextLevel = (nextLevel?.amount ?? 0) - (volume ?? 0) + const hasReachedMaxLevel = + (LEVELS[LEVELS.length - 1]?.amount ?? 500_000) <= (volume ?? 0) return (
@@ -124,8 +123,7 @@ export default function NextLevel({ {i + 1} @@ -162,7 +160,7 @@ export default function NextLevel({ ))} {LEVELS.map((level, i) => (
- {level.nextBoost} + {level.boost}x
))} @@ -198,7 +196,7 @@ export default function NextLevel({

Unlock the {nextLevel?.rankString} level and enjoy a{" "} - {nextLevel?.nextBoost} boost + {nextLevel?.boost}x boost {" "} in the upcoming week by elevating your volume by an additional{" "} @@ -213,7 +211,7 @@ export default function NextLevel({

You did it! You're at the very top next week enjoying a{" "} - {nextLevel?.nextBoost} boost! + {LEVELS[LEVELS.length - 1]?.boost}x boost!

diff --git a/app/points/_components/rank.tsx b/app/points/_components/rank.tsx index 1755ca9f..5b1f0b17 100644 --- a/app/points/_components/rank.tsx +++ b/app/points/_components/rank.tsx @@ -1,16 +1,12 @@ import { cn } from "@/utils" +import { formatNumber } from "@/utils/numbers" import BoxContainer from "./box-container" -import { useUserRank } from "./leaderboard/use-leaderboard" +import { useLeaderboard, useUserPoints } from "./leaderboard/use-leaderboard" -type Props = { - className?: string - rank?: number - totalTraders?: number -} - -export default function Rank({ className, totalTraders = 39059 }: Props) { - const { data } = useUserRank() - const rank = data?.[0]?.rank ?? -1 +export default function Rank({ className }: { className?: string }) { + const { data } = useUserPoints() + const leaderboardQuery = useLeaderboard() + const rank = data?.rank ?? -1 const rankLabel = rank > 0 ? rank : "Unranked" return ( @@ -42,9 +38,12 @@ export default function Rank({ className, totalTraders = 39059 }: Props) { {rankLabel}
- {/*
- of {totalTraders} traders -
*/} + {leaderboardQuery.data?.leaderboard_length ? ( +
+ of {formatNumber(leaderboardQuery.data?.leaderboard_length)}{" "} + traders +
+ ) : undefined}
diff --git a/app/points/_components/rewards.tsx b/app/points/_components/rewards.tsx index 74d158c4..6cff5672 100644 --- a/app/points/_components/rewards.tsx +++ b/app/points/_components/rewards.tsx @@ -1,29 +1,48 @@ +import { addDays, formatDistanceToNow } from "date-fns" + +import InfoTooltip from "@/components/info-tooltip" import { MangroveLogo } from "@/svgs" import { cn } from "@/utils" +import { formatNumber } from "@/utils/numbers" +import { useUserPoints } from "./leaderboard/use-leaderboard" -type Props = { - points?: number -} +export default function Rewards() { + const { data: userPoints } = useUserPoints() + const totalPoints = Number(userPoints?.total_points ?? 0) + + const latestUpdatedDate = userPoints + ? new Date(userPoints.last_updated_timestamp) + : undefined + const nextUpdateDate = latestUpdatedDate + ? addDays(latestUpdatedDate, 1) + : undefined + + const timeUntilNextUpdate = nextUpdateDate + ? formatDistanceToNow(nextUpdateDate, { includeSeconds: true }) + : "" -export default function Rewards({ points = 12450 }: Props) { return (
Total points
- + - {points} + {formatNumber(totalPoints)}
- update every 24 hours {/* update in 22h 10m{" "} */} - {/* + update in {timeUntilNextUpdate} {/* update in 22h 10m{" "} */} + Your total points, recalculated every 24 hours. - */} +
) diff --git a/app/points/_components/total-points.tsx b/app/points/_components/total-points.tsx index 79e558e9..8c37a3b9 100644 --- a/app/points/_components/total-points.tsx +++ b/app/points/_components/total-points.tsx @@ -1,23 +1,29 @@ import { cn } from "@/utils" -import { useUserRank } from "./leaderboard/use-leaderboard" +import { formatNumber } from "@/utils/numbers" +import { useUserPoints } from "./leaderboard/use-leaderboard" export default function TotalPoints() { - const { data } = useUserRank() + const { data: userPoints } = useUserPoints() const points = [ { id: "Trader points", - points: Number(data?.[0]?.taker_points ?? 0), + points: Number(userPoints?.taker_points ?? 0), color: "bg-green-caribbean", }, { id: "Liquidity providing points", - points: Number(data?.[0]?.maker_points ?? 0), + points: Number(userPoints?.maker_points ?? 0), color: "bg-[#8F5AE8]", }, + { + id: "Community points", + points: Number(userPoints?.community_points ?? 0), + color: "bg-white", + }, { id: "Referral points", - points: Number(data?.[0]?.referees_points ?? 0), + points: Number(userPoints?.referees_points ?? 0), color: "bg-green-bangladesh", }, ] @@ -28,7 +34,7 @@ export default function TotalPoints() {
Total
- {totalPoints} points + {formatNumber(totalPoints)} points
@@ -63,7 +69,7 @@ export default function TotalPoints() { > {item.id} - {item.points} + {formatNumber(item.points)} ({percentage.toFixed(0)}%) diff --git a/app/points/constants/index.ts b/app/points/constants/index.ts index abd08aee..30b8998f 100644 --- a/app/points/constants/index.ts +++ b/app/points/constants/index.ts @@ -1,26 +1,47 @@ export const LEVELS = [ { - amount: 1_000, - nextBoost: "1.75x", + amount: 10_000, + boost: 1.75, rankString: "1st", }, { - amount: 10_000, - nextBoost: "2.5x", + amount: 20_000, + boost: 2.5, rankString: "2nd", }, { amount: 50_000, - nextBoost: "3x", + boost: 3, rankString: "3rd", }, { amount: 100_000, - nextBoost: "3.5x", + boost: 3.5, rankString: "4th", }, { amount: 500_000, - nextBoost: "4x", + boost: 4, }, ] + +export function getLevels(volume?: number) { + if (!volume) { + return { + currentIndex: 0, + nextIndex: 0, + nextLevel: LEVELS[0], + } + } + + const currentIndex = + LEVELS.findIndex((l) => l.amount > volume) === -1 + ? LEVELS.length - 1 + : LEVELS.findIndex((l) => l.amount > volume) - 1 + const nextIndex = currentIndex + 1 + return { + currentIndex, + nextIndex, + nextLevel: LEVELS[nextIndex], + } +} diff --git a/app/points/layout.tsx b/app/points/layout.tsx index a7739a14..3ee770e4 100644 --- a/app/points/layout.tsx +++ b/app/points/layout.tsx @@ -1,8 +1,14 @@ +import { Metadata } from "next" import React from "react" import { Navbar } from "@/components/navbar" import { Caption } from "@/components/typography/caption" +export const metadata: Metadata = { + title: "Points | Mangrove DEX", + description: "Points on Mangrove DEX", +} + export default function Layout({ children }: React.PropsWithChildren) { return ( <> diff --git a/app/points/page.tsx b/app/points/page.tsx index fa959596..ce216a9b 100644 --- a/app/points/page.tsx +++ b/app/points/page.tsx @@ -1,10 +1,12 @@ "use client" import { useAccount } from "wagmi" + import { ConnectWalletBanner } from "./_components/connect-wallet-banner" import CurrentBoost from "./_components/current-boost" import { JoinProgramBanner } from "./_components/join-program-banner" import { Leaderboard } from "./_components/leaderboard/table" +import { useUserBoosts } from "./_components/leaderboard/use-leaderboard" import NextLevel from "./_components/next-level" import Rank from "./_components/rank" import Rewards from "./_components/rewards" @@ -12,6 +14,11 @@ import TotalPoints from "./_components/total-points" export default function Page() { const { isConnected } = useAccount() + const { data: userBoosts } = useUserBoosts() + const userBoost = userBoosts?.[0] + const currentBoost = Number(userBoost?.boost ?? 1) + const volume = Number(userBoost?.volume ?? 0) + return isConnected ? (
@@ -21,9 +28,18 @@ export default function Page() {
- + - +
diff --git a/app/points/schemas/boosts.ts b/app/points/schemas/boosts.ts new file mode 100644 index 00000000..ada2c671 --- /dev/null +++ b/app/points/schemas/boosts.ts @@ -0,0 +1,21 @@ +import { z } from "zod" + +export const boostsSchema = z.array( + z.object({ + account: z.string(), + type: z.string(), + boost: z.string().transform(parseFloat).transform(Math.floor), + volume: z.string().transform(parseFloat).transform(Math.floor), + }), +) + +export type Boosts = z.infer + +export function parseBoosts(data: unknown) { + try { + return boostsSchema.parse(data) + } catch (error) { + console.error("Invalid format for boosts: ", data, error) + return null + } +} diff --git a/app/points/schemas/leaderboard.ts b/app/points/schemas/leaderboard.ts new file mode 100644 index 00000000..16b3524f --- /dev/null +++ b/app/points/schemas/leaderboard.ts @@ -0,0 +1,31 @@ +import { z } from "zod" + +const leaderboardEntrySchema = z.object({ + rank: z.number(), + account: z.string(), + taker_points: z.string().transform(parseFloat).transform(Math.floor), + maker_points: z.string().transform(parseFloat).transform(Math.floor), + referees_points: z.string().transform(parseFloat).transform(Math.floor), + total_points: z.string().transform(parseFloat).transform(Math.floor), + boost: z.string().transform(parseFloat), + community_points: z.string().transform(parseFloat), +}) + +export const leaderboardSchema = z.object({ + leaderboard: z.array(leaderboardEntrySchema), + last_updated_timestamp: z.number(), + last_updated_block: z.number(), + leaderboard_length: z.number(), +}) + +export type Leaderboard = z.infer +export type LeaderboardEntry = z.infer + +export function parseLeaderboard(data: unknown) { + try { + return leaderboardSchema.parse(data) + } catch (error) { + console.error("Invalid format for leaderboard: ", data, error) + return null + } +} diff --git a/app/points/schemas/points.ts b/app/points/schemas/points.ts new file mode 100644 index 00000000..166e8354 --- /dev/null +++ b/app/points/schemas/points.ts @@ -0,0 +1,25 @@ +import { z } from "zod" + +export const pointsSchema = z.object({ + last_updated_block: z.number(), + rank: z.number(), + account: z.string(), + taker_points: z.string().transform(parseFloat).transform(Math.floor), + maker_points: z.string().transform(parseFloat).transform(Math.floor), + referees_points: z.string().transform(parseFloat).transform(Math.floor), + total_points: z.string().transform(parseFloat).transform(Math.floor), + last_updated_timestamp: z.number(), + boost: z.string().transform(parseFloat), + community_points: z.string().transform(parseFloat), +}) + +export type Points = z.infer + +export function parsePoints(data: unknown) { + try { + return pointsSchema.parse(data) + } catch (error) { + console.error("Invalid format for points: ", data, error) + return null + } +} diff --git a/app/referrals/layout.tsx b/app/referrals/layout.tsx index e70c8586..78edae93 100644 --- a/app/referrals/layout.tsx +++ b/app/referrals/layout.tsx @@ -1,7 +1,13 @@ +import { Metadata } from "next" import React from "react" import { Navbar } from "@/components/navbar" +export const metadata: Metadata = { + title: "Referrals | Mangrove DEX", + description: "Referrals on Mangrove DEX", +} + export default function Layout({ children }: React.PropsWithChildren) { return ( <> diff --git a/app/strategies/(list)/_components/tables/strategies/hooks/use-strategies.ts b/app/strategies/(list)/_components/tables/strategies/hooks/use-strategies.ts index 9254fae5..07c2bafd 100644 --- a/app/strategies/(list)/_components/tables/strategies/hooks/use-strategies.ts +++ b/app/strategies/(list)/_components/tables/strategies/hooks/use-strategies.ts @@ -1,7 +1,7 @@ "use client" import { useQuery } from "@tanstack/react-query" -import { useAccount } from "wagmi" +import { useAccount, useChainId } from "wagmi" import { useWhitelistedMarketsInfos } from "@/hooks/use-whitelisted-markets-infos" import useMangrove from "@/providers/mangrove" @@ -21,6 +21,7 @@ export function useStrategies({ select, }: Params = {}) { const { mangrove } = useMangrove() + const chainId = useChainId() const { address, isConnected } = useAccount() const { indexerSdk } = useIndexerSdk() const { data: knownTokens } = useWhitelistedMarketsInfos(mangrove, { @@ -34,10 +35,10 @@ export function useStrategies({ }) return useQuery({ - queryKey: ["strategies", address, first, skip], + queryKey: ["strategies", chainId, address, first, skip], queryFn: async () => { try { - if (!(indexerSdk && address && knownTokens)) return [] + if (!(indexerSdk && address && knownTokens && chainId)) return [] const result = await indexerSdk.getKandels({ owner: address.toLowerCase(), first, @@ -55,7 +56,7 @@ export function useStrategies({ meta: { error: "Unable to retrieve all strategies", }, - enabled: !!(isConnected && indexerSdk && address && knownTokens), + enabled: !!(isConnected && indexerSdk && address && knownTokens && chainId), retry: false, }) } diff --git a/app/strategies/(list)/_components/tables/strategies/hooks/use-table.tsx b/app/strategies/(list)/_components/tables/strategies/hooks/use-table.tsx index 8b6cb9d3..e1a1cb6b 100644 --- a/app/strategies/(list)/_components/tables/strategies/hooks/use-table.tsx +++ b/app/strategies/(list)/_components/tables/strategies/hooks/use-table.tsx @@ -7,10 +7,12 @@ import { getSortedRowModel, useReactTable, } from "@tanstack/react-table" +import Big from "big.js" import Link from "next/link" import React from "react" import { useAccount } from "wagmi" +import useStrategyStatus from "@/app/strategies/(shared)/_hooks/use-strategy-status" import { IconButton } from "@/components/icon-button" import { Close, Pen } from "@/svgs" import { shortenAddress } from "@/utils/wallet" @@ -32,6 +34,7 @@ type Params = { export function useTable({ type, data, onCancel, onManage }: Params) { const { chain } = useAccount() + const columns = React.useMemo( () => [ columnHelper.display({ @@ -90,13 +93,24 @@ export function useTable({ type, data, onCancel, onManage }: Params) { columnHelper.display({ header: "Value", cell: ({ row }) => { - const { base, quote, depositedBase, depositedQuote } = row.original + const { base, quote, offers } = row.original + const asksOffers = offers?.filter((item) => item.offerType === "asks") + const bidsOffers = offers?.filter((item) => item.offerType === "bids") + + const baseAmountDeposited = asksOffers?.reduce((acc, curr) => { + return acc.add(Big(curr.gives)) + }, Big(0)) + + const quoteAmountDeposited = bidsOffers?.reduce((acc, curr) => { + return acc.add(Big(curr.gives)) + }, Big(0)) + return ( ) }, @@ -105,18 +119,18 @@ export function useTable({ type, data, onCancel, onManage }: Params) { header: "Status", cell: ({ row }) => { const { base, quote, address, offers } = row.original - return ( - - ) + const { data } = useStrategyStatus({ + address, + base, + quote, + offers, + }) + + return }, }), columnHelper.display({ - header: "Return (%)", + header: "PnL (%)", cell: ({ row }) => { const { return: ret } = row.original return ( @@ -134,29 +148,39 @@ export function useTable({ type, data, onCancel, onManage }: Params) { // TODO: get from indexer columnHelper.display({ header: "Reward", - cell: () => "3.39%", + cell: () => "-", }), columnHelper.display({ id: "actions", header: () =>
Action
, - cell: ({ row }) => ( -
- onManage(row.original)} - > - - - onCancel(row.original)} - > - - -
- ), + cell: ({ row }) => { + return ( +
+ { + e.preventDefault() + e.stopPropagation() + onManage(row.original) + }} + > + + + { + e.preventDefault() + e.stopPropagation() + onCancel(row.original) + }} + > + + +
+ ) + }, }), ], [onManage, onCancel], diff --git a/app/strategies/(list)/_components/tables/strategies/strategies.tsx b/app/strategies/(list)/_components/tables/strategies/strategies.tsx index 78a90879..9971bcbd 100644 --- a/app/strategies/(list)/_components/tables/strategies/strategies.tsx +++ b/app/strategies/(list)/_components/tables/strategies/strategies.tsx @@ -2,7 +2,9 @@ import { useRouter } from "next/navigation" import React from "react" +import CloseStrategyDialog from "@/app/strategies/[address]/_components/parameters/dialogs/close" import { DataTable } from "@/components/ui/data-table/data-table" +import useMangrove from "@/providers/mangrove" import useMarket from "@/providers/market" import type { Strategy } from "../../../_schemas/kandels" import { useStrategies } from "./hooks/use-strategies" @@ -18,7 +20,9 @@ export function Strategies({ type }: Props) { pageSize: 10, }) const { market } = useMarket() + const { marketsInfoQuery } = useMangrove() const { data } = useStrategies() + const { data: openMarkets } = marketsInfoQuery const { data: count } = useStrategies({ select: (strategies) => strategies.length, }) @@ -29,28 +33,52 @@ export function Strategies({ type }: Props) { }) // selected strategy to cancel - const [, setStrategyToCancel] = React.useState() + const [closeStrategy, setCloseStrategy] = React.useState() const table = useTable({ type, data, onManage: (strategy: Strategy) => { - push(`/strategies/${strategy.address}`) + console.log(strategy) + const baseToken = openMarkets?.find( + (item) => + item.base.address.toLowerCase() === strategy.base.toLowerCase(), + )?.base + const quoteToken = openMarkets?.find( + (item) => + item.quote.address.toLowerCase() === strategy.quote.toLowerCase(), + )?.quote + + push( + `/strategies/${strategy.address}/edit?market=${baseToken?.id},${quoteToken?.id}`, + ) }, - onCancel: setStrategyToCancel, // TODO: implement cancel dialog + onCancel: (strategy: Strategy) => setCloseStrategy(strategy), }) return ( - + <> + + // note: lost of context after redirecting with push method here + // push(`/strategies/${strategy?.address}`) + (window.location.href = `/strategies/${strategy?.address}`) + } + pagination={{ + onPageChange: setPageDetails, + page, + pageSize, + count, + }} + /> + setCloseStrategy(undefined)} + /> + ) } diff --git a/app/strategies/(list)/_schemas/kandels.ts b/app/strategies/(list)/_schemas/kandels.ts index f29718c5..718d8bdc 100644 --- a/app/strategies/(list)/_schemas/kandels.ts +++ b/app/strategies/(list)/_schemas/kandels.ts @@ -16,6 +16,7 @@ export const bidsOrAsksSchema = z.union([z.literal("bids"), z.literal("asks")]) export const kandelTypeStringSchema = z.union([ z.literal("Kandel"), + z.literal("SmartKandel"), z.literal("KandelAAVE"), ]) diff --git a/app/strategies/(list)/layout.tsx b/app/strategies/(list)/layout.tsx index d87a6351..e4800c1f 100644 --- a/app/strategies/(list)/layout.tsx +++ b/app/strategies/(list)/layout.tsx @@ -1,3 +1,4 @@ +import { Metadata } from "next" import React from "react" import { KandelStrategiesProvider } from "@/app/strategies/(list)/_providers/kandel-strategies" @@ -5,6 +6,11 @@ import { Navbar } from "@/components/navbar" import { IndexerSdkProvider } from "@/providers/mangrove-indexer" import { MarketProvider } from "@/providers/market" +export const metadata: Metadata = { + title: "Strategies | Mangrove DEX", + description: "Strategies on Mangrove DEX", +} + export default function Layout({ children }: React.PropsWithChildren) { return ( diff --git a/app/strategies/(shared)/_components/refill-dialog.tsx b/app/strategies/(shared)/_components/refill-dialog.tsx new file mode 100644 index 00000000..119dc646 --- /dev/null +++ b/app/strategies/(shared)/_components/refill-dialog.tsx @@ -0,0 +1,49 @@ +import Dialog from "@/components/dialogs/dialog" +import { Button } from "@/components/ui/button" +import { MergedOffer } from "../../[address]/_utils/inventory" +import { useRefillOffer } from "../_hooks/use-refill-offer" + +type Props = { + offer?: MergedOffer + onClose: () => void +} + +export default function RefillOfferDialog({ offer, onClose }: Props) { + if (!offer) return null + + const refill = useRefillOffer({ + offer, + onCancel: onClose, + }) + + return ( + + Are you sure you want to refill this order? + +
+ + + + +
+
+
+ ) +} diff --git a/app/strategies/(shared)/_components/status.tsx b/app/strategies/(shared)/_components/status.tsx index f98dba15..6cb0a723 100644 --- a/app/strategies/(shared)/_components/status.tsx +++ b/app/strategies/(shared)/_components/status.tsx @@ -1,33 +1,28 @@ -import { Strategy } from "@/app/strategies/(list)/_schemas/kandels" import { Title } from "@/components/typography/title" import { Skeleton } from "@/components/ui/skeleton" import { Check, Close, Closed } from "@/svgs" import { cn } from "@/utils" -import useStrategyStatus from "../_hooks/use-strategy-status" -type Props = Pick - -export default function Status({ address, base, quote, offers }: Props) { - const { data } = useStrategyStatus({ - address, - base, - quote, - offers, - }) - const status = data?.status +type Props = { + status: "active" | "inactive" | "closed" | undefined +} +export default function Status({ status }: Props) { if (!status) return const Icon = status === "active" ? Check : status === "inactive" ? Close : Closed return (
diff --git a/app/strategies/(shared)/_hooks/use-activate-smart-router.ts b/app/strategies/(shared)/_hooks/use-activate-smart-router.ts new file mode 100644 index 00000000..5d96a1ae --- /dev/null +++ b/app/strategies/(shared)/_hooks/use-activate-smart-router.ts @@ -0,0 +1,74 @@ +import useMangrove from "@/providers/mangrove" +import { useMutation } from "@tanstack/react-query" +import { Address, parseAbi } from "viem" +import { useAccount, usePublicClient, useWalletClient } from "wagmi" + +const MangroveOrderABI = parseAbi([ + "function ROUTER_FACTORY() external view returns (address)", + "function ROUTER_IMPLEMENTATION() external view returns (address)", +]) + +const RouterProxyFactoryABI = parseAbi([ + "function instantiate(address owner, address routerImplementation) public returns (address proxy, bool created)", +]) + +const SmartRouterABI = parseAbi([ + "function isBound(address mkr) public view returns (bool)", + "function bind(address makerContract) public", +]) + +export function useActivateStrategySmartRouter(kandelAddress: string) { + const { address } = useAccount() + const { mangrove } = useMangrove() + const publicClient = usePublicClient() + const { data: walletClient } = useWalletClient() + const orderContract = mangrove?.orderContract.address + + return useMutation({ + mutationFn: async () => { + try { + if (!publicClient || !address || !orderContract || !walletClient) return + + const ROUTER_FACTORY = await publicClient.readContract({ + address: orderContract as Address, + abi: MangroveOrderABI, + functionName: "ROUTER_FACTORY", + }) + + const ROUTER_IMPLEMENTATION = await publicClient.readContract({ + address: orderContract as Address, + abi: MangroveOrderABI, + functionName: "ROUTER_IMPLEMENTATION", + }) + + const { + result: [proxy], + } = await publicClient.simulateContract({ + address: ROUTER_FACTORY, + abi: RouterProxyFactoryABI, + functionName: "instantiate", + args: [address, ROUTER_IMPLEMENTATION], + }) + + const tx = await walletClient?.writeContract({ + address: proxy, + abi: SmartRouterABI, + functionName: "bind", + args: [kandelAddress as Address], + }) + + const result = await publicClient.waitForTransactionReceipt({ + hash: tx, + }) + return result + } catch (error) { + console.error(error) + throw new Error("Smart router activation failed.") + } + }, + meta: { + error: "Smart router activation failed.", + success: "Smart router activated successfully.", + }, + }) +} diff --git a/app/strategies/(shared)/_hooks/use-liquidity-sourcing.ts b/app/strategies/(shared)/_hooks/use-liquidity-sourcing.ts new file mode 100644 index 00000000..9138fcb5 --- /dev/null +++ b/app/strategies/(shared)/_hooks/use-liquidity-sourcing.ts @@ -0,0 +1,122 @@ +import { Token } from "@mangrovedao/mangrove.js" +import React from "react" +import { DefaultStrategyLogics } from "../type" + +type Props = { + sendFrom?: string + receiveTo?: string + mangroveLogics: DefaultStrategyLogics[] + fundOwner?: string + sendToken?: Token + receiveToken?: Token +} + +type BalanceLogic = { + formatted: string + balance: number +} + +// export function useAbleToken(logic: DefaultStrategyLogics, token: Token) { +// return useQuery({ +// queryKey: ["availableLogic"], +// queryFn: async () => { +// return await logic?.overlying(token) +// }, + +// enabled: !!(logic && token), +// }).data +// } + +export default function useLiquiditySourcing({ + sendToken, + receiveToken, + sendFrom, + receiveTo, + mangroveLogics, + fundOwner, +}: Props) { + const [sendFromBalance, setSendFromBalance] = React.useState< + BalanceLogic | undefined + >() + const [receiveToBalance, setReceiveToBalance] = React.useState< + BalanceLogic | undefined + >() + const [sendFromLogics, setSendFromLogics] = + React.useState<DefaultStrategyLogics[]>() + const [receiveToLogics, setReceiveToLogics] = + React.useState<DefaultStrategyLogics[]>() + + const getLogics = async ( + token: Token, + setLogics: (logics: DefaultStrategyLogics[]) => void, + ) => { + const usableLogics = mangroveLogics.map(async (logic) => { + try { + if (!logic) return + const logicToken = await logic.overlying(token) + if (logicToken) { + return logic + } + } catch (error) { + // if the logic is not available for the token, we catch the error and return + return + } + }) + const resolvedLogics = await Promise.all(usableLogics) + setLogics(resolvedLogics) + } + + const getBalance = async ( + token: Token, + fundOwner: string, + setBalance: (logics: BalanceLogic | undefined) => void, + logicId?: string, + ) => { + try { + if (logicId === "simple") { + setBalance(undefined) + return + } + + const selectedLogic = mangroveLogics.find( + (logic) => logic?.id === logicId, + ) + if (!selectedLogic) return + const logicBalance = await selectedLogic.balanceOfFromLogic( + token, + fundOwner, + ) + + setBalance({ + formatted: logicBalance.toNumber().toFixed(token?.displayedDecimals), + balance: logicBalance.toNumber(), + }) + } catch (error) { + setBalance(undefined) + return + } + } + + React.useEffect(() => { + if (!sendToken || !receiveToken) return + getLogics(receiveToken, setReceiveToLogics) + getLogics(sendToken, setSendFromLogics) + }, [sendToken, receiveToken, fundOwner]) + + React.useEffect(() => { + if (!sendToken || !fundOwner) return + getBalance(sendToken, fundOwner, setSendFromBalance, sendFrom) + }, [sendFrom, sendToken, fundOwner]) + + React.useEffect(() => { + if (!receiveToken || !fundOwner) return + getBalance(receiveToken, fundOwner, setReceiveToBalance, receiveTo) + }, [receiveTo, receiveToken, fundOwner]) + + return { + sendFromLogics, + receiveToLogics, + sendFromBalance, + receiveToBalance, + } +} diff --git a/app/strategies/(shared)/_hooks/use-refill-offer.ts b/app/strategies/(shared)/_hooks/use-refill-offer.ts new file mode 100644 index 00000000..c4fa02a9 --- /dev/null +++ b/app/strategies/(shared)/_hooks/use-refill-offer.ts @@ -0,0 +1,110 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" + +import { useResolveWhenBlockIsIndexed } from "@/hooks/use-resolve-when-block-is-indexed" +import { useLoadingStore } from "@/stores/loading.store" +import Big from "big.js" +import { useRefillRequirements } from "../../[address]/_hooks/use-refill-requirements" +import useKandel from "../../[address]/_providers/kandel-strategy" +import { MergedOffer } from "../../[address]/_utils/inventory" +import useStrategyStatus from "./use-strategy-status" + +type Props = { + offer: MergedOffer + onCancel?: () => void +} + +export function useRefillOffer({ offer, onCancel }: Props) { + const { strategyQuery, strategyStatusQuery, strategyAddress } = useKandel() + const { data } = useRefillRequirements({ + offer, + }) + + const { market } = strategyStatusQuery.data ?? {} + + const { data: strategy } = useStrategyStatus({ + address: strategyAddress, + base: market?.base.symbol, + quote: market?.quote.symbol, + offers: strategyQuery.data?.offers, + }) + + const queryClient = useQueryClient() + const resolveWhenBlockIsIndexed = useResolveWhenBlockIsIndexed() + const [startLoading] = useLoadingStore((state) => [ + state.startLoading, + state.stopLoading, + ]) + + return useMutation({ + /* + * We introduce a mutationKey to the useCancelOrder hook. This allows us to + * handle multiple order retractions simultaneously, without them sharing the + * same mutation state. This is crucial for maintaining independent states + * for each retraction operation. + */ + mutationKey: ["refillOffer", offer.index], + mutationFn: async () => { + try { + if (!strategy || !strategyQuery) + throw new Error("Could not refill offer") + + const { stratInstance } = strategy + + const singleOfferDistributionChunk = { + bids: + offer.offerType === "bids" + ? [ + { + index: offer.index, + tick: offer.tick, + gives: Big(data?.minimumVolume || 1), + }, + ] + : [], + asks: + offer.offerType === "asks" + ? [ + { + index: offer.index, + tick: offer.tick, + gives: Big(data?.minimumVolume || 1), + }, + ] + : [], + } + + const transaction = await stratInstance?.populateGeneralChunks({ + distributionChunks: [singleOfferDistributionChunk], + }) + + const txs = await Promise.all(transaction.map((tx) => tx?.wait())) + + return { txs } + } catch (error) { + console.error(error) + } + }, + onSuccess: async (data) => { + try { + if (!data) return + const { txs } = data + await Promise.all( + txs.map(async (tx) => { + await resolveWhenBlockIsIndexed.mutateAsync({ + blockNumber: tx?.blockNumber, + }) + }), + ) + queryClient.invalidateQueries({ queryKey: ["strategy-status"] }) + queryClient.invalidateQueries({ queryKey: ["strategy"] }) + onCancel?.() + } catch (error) { + console.error(error) + } + }, + meta: { + error: `Failed to refill the offer`, + success: `The offer has been successfully refilled`, + }, + }) +} diff --git a/app/strategies/(shared)/_hooks/use-smart-router.ts b/app/strategies/(shared)/_hooks/use-smart-router.ts new file mode 100644 index 00000000..0c4ffbdd --- /dev/null +++ b/app/strategies/(shared)/_hooks/use-smart-router.ts @@ -0,0 +1,90 @@ +import useMangrove from "@/providers/mangrove" +import { useQuery } from "@tanstack/react-query" +import { Address, parseAbi } from "viem" +import { useAccount, usePublicClient, useWalletClient } from "wagmi" + +const MangroveOrderABI = parseAbi([ + "function ROUTER_FACTORY() external view returns (address)", + "function ROUTER_IMPLEMENTATION() external view returns (address)", +]) + +const RouterProxyFactoryABI = parseAbi([ + "function instantiate(address owner, address routerImplementation) public returns (address proxy, bool created)", +]) + +const SmartRouterABI = parseAbi([ + "function isBound(address mkr) public view returns (bool)", + "function bind(address makerContract) public", +]) + +export function useStrategySmartRouter({ + kandelAddress, +}: { + kandelAddress: string +}) { + const { address } = useAccount() + const { mangrove } = useMangrove() + const publicClient = usePublicClient() + const { data: walletClient } = useWalletClient() + + const orderContract = mangrove?.orderContract.address + + return useQuery({ + queryKey: ["strategy-smart-router", orderContract, publicClient, address], + queryFn: async () => { + try { + if (!publicClient || !address || !orderContract || !walletClient) return + + const ROUTER_FACTORY = await publicClient.readContract({ + address: orderContract as Address, + abi: MangroveOrderABI, + functionName: "ROUTER_FACTORY", + }) + + const ROUTER_IMPLEMENTATION = await publicClient.readContract({ + address: orderContract as Address, + abi: MangroveOrderABI, + functionName: "ROUTER_IMPLEMENTATION", + }) + + const { + result: [proxy], + request, + } = await publicClient.simulateContract({ + address: ROUTER_FACTORY, + abi: RouterProxyFactoryABI, + functionName: "instantiate", + args: [address, ROUTER_IMPLEMENTATION], + }) + + let isBound = false + + try { + isBound = await publicClient.readContract({ + address: proxy, + abi: SmartRouterABI, + functionName: "isBound", + args: [kandelAddress as Address], + }) + } catch (error) {} + + return { isBound } + } catch (error) { + console.error(error) + return { isBound: false } + } + }, + meta: { + error: + "Unable to verify amplified order smart-router deployment and activation.", + }, + enabled: !!( + address && + publicClient && + walletClient && + orderContract && + mangrove + ), + retry: false, + }) +} diff --git a/app/strategies/(shared)/_hooks/use-strategy-status.ts b/app/strategies/(shared)/_hooks/use-strategy-status.ts index 4d64fb0f..edebfb79 100644 --- a/app/strategies/(shared)/_hooks/use-strategy-status.ts +++ b/app/strategies/(shared)/_hooks/use-strategy-status.ts @@ -42,18 +42,16 @@ export default function useStrategyStatus({ const stratInstance = await kandelStrategies.instance({ address: address, market, + type: "smart", }) - const asksBalance = await stratInstance.getBalance("asks") // base - const bidsBalance = await stratInstance.getBalance("bids") // quote - const hasBalance = asksBalance.gt(0) && bidsBalance.gt(0) const anyLiveOffers = offers.some((x) => x?.live === true) let isOutOfRange = false let unexpectedDeadOffers = false let offerStatuses: Statuses | null = null let status: Status = "unknown" if (!anyLiveOffers) { - status = hasBalance ? "inactive" : "closed" + status = "closed" } else { const bids = offers.filter((x) => x.offerType === "bids") const asks = offers.filter((x) => x.offerType === "asks") @@ -96,8 +94,6 @@ export default function useStrategyStatus({ return { status, - asksBalance, - bidsBalance, midPrice, market, book, diff --git a/app/strategies/(shared)/type.ts b/app/strategies/(shared)/type.ts new file mode 100644 index 00000000..0fdeacd9 --- /dev/null +++ b/app/strategies/(shared)/type.ts @@ -0,0 +1,17 @@ +import { PacFinanceLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/AaveV3/PacFinanceLogic" +import { SimpleAaveLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/AaveV3/SimpleAaveLogic" +import { ZeroLendLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/AaveV3/ZeroLendLogic" +import { OrbitLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/OrbitLogic" +import { SimpleLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/SimpleLogic" +import { MonoswapV3Logic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/UniV3/MonoswapV3Logic" +import { ThrusterV3Logic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/UniV3/ThrusterV3Logic" + +export type DefaultStrategyLogics = + | SimpleLogic + | SimpleAaveLogic + | OrbitLogic + | ZeroLendLogic + | MonoswapV3Logic + | ThrusterV3Logic + | PacFinanceLogic + | undefined diff --git a/app/strategies/[address]/_components/block-explorer.tsx b/app/strategies/[address]/_components/block-explorer.tsx index 9204fff4..4374f4d8 100644 --- a/app/strategies/[address]/_components/block-explorer.tsx +++ b/app/strategies/[address]/_components/block-explorer.tsx @@ -11,6 +11,7 @@ type Props = { blockExplorerUrl?: string description?: boolean copy?: boolean + type?: "address" | "tx" } function BlockExplorer({ @@ -18,9 +19,10 @@ function BlockExplorer({ address, blockExplorerUrl, copy, + type = "tx", }: Props) { return ( - <div className="flex items-center text-sm font-normal justify-between w-full"> + <div className="flex items-center text-sm font-normal justify-between"> {description ? ( <span className="text-cloud-300">View on block explorer:</span> ) : undefined} @@ -35,7 +37,7 @@ function BlockExplorer({ <Link rel="noopener noreferrer" target="_blank" - href={`${blockExplorerUrl}/tx/${address}`} + href={`${blockExplorerUrl}/${type}/${address}`} > <span>{shortenAddress(address ?? "")}</span> {!copy && <ExternalLink className="mr-2 h-4 w-4" />} diff --git a/app/strategies/[address]/_components/history/history.tsx b/app/strategies/[address]/_components/history/history.tsx index 4e8ec8e2..ac053087 100644 --- a/app/strategies/[address]/_components/history/history.tsx +++ b/app/strategies/[address]/_components/history/history.tsx @@ -8,17 +8,16 @@ import { CustomTabsTrigger, } from "@/components/custom-tabs" import { renderElement } from "@/utils/render" -import HistoryTable from "./table/history-table" import ParametersTable from "./table/parameters-table" export enum ManageTabs { - DEPOSIT_WITHDRAW = "Deposit / Withdraw", PARAMETERS = "parameters", + // DEPOSIT_WITHDRAW = "Published / Unpublished", } const TABS_CONTENT = { - [ManageTabs.DEPOSIT_WITHDRAW]: <HistoryTable />, [ManageTabs.PARAMETERS]: <ParametersTable />, + // [ManageTabs.DEPOSIT_WITHDRAW]: <HistoryTable />, } function Tabs(props: React.ComponentPropsWithoutRef<typeof CustomTabs>) { diff --git a/app/strategies/[address]/_components/history/table/parameters-table.tsx b/app/strategies/[address]/_components/history/table/parameters-table.tsx index 333271e1..a35f9388 100644 --- a/app/strategies/[address]/_components/history/table/parameters-table.tsx +++ b/app/strategies/[address]/_components/history/table/parameters-table.tsx @@ -1,24 +1,33 @@ +import { useMemo } from "react" + import { DataTable } from "@/components/ui/data-table/data-table" import useKandel from "../../../_providers/kandel-strategy" import { useParameters } from "../../parameters/hook/use-parameters" -import { Parameters, useParametersTables } from "./use-parameters-table" +import { Parameters, useParametersTable } from "./use-parameters-table" export default function HistoryTable() { - const { strategyQuery, strategyStatusQuery } = useKandel() + const { strategyQuery, strategyStatusQuery, baseToken, quoteToken } = + useKandel() - const { currentParameter, depositedBase } = useParameters() - const { creationDate, length, priceRatio } = currentParameter + const { currentParameter, publishedBase, publishedQuote } = useParameters() + const { creationDate, length, stepSize, lockedBounty, nativeSymbol } = + currentParameter - const table = useParametersTables({ - data: [ + const data: Parameters[] = useMemo( + () => [ { date: creationDate, - spread: "-", pricePoints: length, - amount: depositedBase, - ratio: priceRatio?.toFixed(4), + stepSize, + amount: `${publishedBase.toFixed(6)} ${baseToken?.symbol} - ${publishedQuote.toFixed(6)} ${quoteToken?.symbol}`, + lockedBounty: `${Number(lockedBounty || "0").toFixed(6)} ${nativeSymbol}`, }, - ] as Parameters[], + ], + [creationDate, length, publishedBase.toFixed(6)], + ) + + const table = useParametersTable({ + data, }) const isLoading = strategyQuery.isLoading || strategyStatusQuery.isLoading diff --git a/app/strategies/[address]/_components/history/table/use-history-table.tsx b/app/strategies/[address]/_components/history/table/use-history-table.tsx index 58fcf061..1f3441d1 100644 --- a/app/strategies/[address]/_components/history/table/use-history-table.tsx +++ b/app/strategies/[address]/_components/history/table/use-history-table.tsx @@ -24,12 +24,12 @@ type Params = { export function useHistoryParams({ data }: Params) { const { chain } = useAccount() - const { baseToken } = useKandel() + const { baseToken, quoteToken } = useKandel() const columns = React.useMemo( () => [ columnHelper.accessor("date", { - header: "date", + header: "Date", cell: ({ row }) => { const { date } = row.original return <div>{formatDate(date)}</div> @@ -43,7 +43,7 @@ export function useHistoryParams({ data }: Params) { const { isDeposit } = row.original return ( <div className="w-full h-full flex justify-end"> - {isDeposit ? "Deposit" : "Withdraw"} + {isDeposit ? "Published" : "Unpublished"} </div> ) }, @@ -53,10 +53,14 @@ export function useHistoryParams({ data }: Params) { id: "amount", header: () => <div className="text-right">Amount</div>, cell: ({ row }) => { - const { amount } = row.original + const { amount, token } = row.original + const amountToken = [baseToken, quoteToken].find( + (item) => item?.address === token, + ) return ( <div className="w-full h-full flex justify-end"> - {amount} {baseToken?.symbol} + {Number(amount).toFixed(amountToken?.displayedDecimals)}{" "} + {amountToken?.symbol} </div> ) }, @@ -69,7 +73,7 @@ export function useHistoryParams({ data }: Params) { const { transactionHash } = row.original const blockExplorerUrl = chain?.blockExplorers?.default.url return ( - <div className="w-full h-full flex justify-end"> + <div className="flex w-full justify-end"> <BlockExplorer address={transactionHash} blockExplorerUrl={blockExplorerUrl} @@ -80,7 +84,7 @@ export function useHistoryParams({ data }: Params) { }, }), ], - [], + [chain?.blockExplorers?.default?.url], ) return useReactTable({ diff --git a/app/strategies/[address]/_components/history/table/use-parameters-table.tsx b/app/strategies/[address]/_components/history/table/use-parameters-table.tsx index 2cca9f1c..b6c884e5 100644 --- a/app/strategies/[address]/_components/history/table/use-parameters-table.tsx +++ b/app/strategies/[address]/_components/history/table/use-parameters-table.tsx @@ -8,67 +8,56 @@ import { useReactTable, } from "@tanstack/react-table" import React from "react" -import { useAccount } from "wagmi" import { formatDate } from "@/utils/date" -import useKandel from "../../../_providers/kandel-strategy" -import BlockExplorer from "../../block-explorer" const columnHelper = createColumnHelper<Parameters>() const DEFAULT_DATA: Parameters[] = [] export type Parameters = { - date: Date - spread: string - ratio: string - pricePoints: string - amount: string - txHash: string + date: Date | undefined + pricePoints: string | null | undefined + amount: string | undefined + stepSize: string | null | undefined + lockedBounty: string | undefined } type Params = { data?: Parameters[] } -export function useParametersTables({ data }: Params) { - const { chain } = useAccount() - const { baseToken } = useKandel() - +export function useParametersTable({ data }: Params) { const columns = React.useMemo( () => [ columnHelper.accessor("date", { - header: "date", + header: "Date", cell: ({ row }) => { const { date } = row.original + if (!date) return <div>N/A</div> return <div>{formatDate(date)}</div> }, }), columnHelper.display({ - id: "spread", - header: () => <div className="text-right">Spread</div>, - cell: ({ row }) => { - const { spread } = row.original - return <div className="w-full h-full flex justify-end">{spread}</div> - }, - }), - - columnHelper.display({ - id: "ratio", - header: () => <div className="text-right">Ratio</div>, + id: "pricePoints", + header: () => <div className="text-right">No. of offers</div>, cell: ({ row }) => { - const { ratio } = row.original - return <div className="w-full h-full flex justify-end">{ratio}</div> + const { pricePoints } = row.original + return ( + <div className="w-full h-full flex justify-end"> + {Number(pricePoints) - 1} + </div> + ) }, }), columnHelper.display({ - id: "pricePoints", - header: () => <div className="text-right">No. of Price Points</div>, + id: "stepSize", + header: () => <div className="text-right">Step size</div>, cell: ({ row }) => { - const { pricePoints } = row.original + const { stepSize } = row.original return ( - <div className="w-full h-full flex justify-end">{pricePoints}</div> + <div className="w-full h-full flex justify-end">{stepSize}</div> ) }, }), @@ -78,27 +67,17 @@ export function useParametersTables({ data }: Params) { header: () => <div className="text-right">Amount</div>, cell: ({ row }) => { const { amount } = row.original - return ( - <div className="w-full h-full flex justify-end"> - {amount} {baseToken?.symbol} - </div> - ) + return <div className="w-full h-full flex justify-end">{amount}</div> }, }), - columnHelper.accessor("txHash", { - id: "txHash", - header: () => <div className="text-right">Transaction Hash</div>, + + columnHelper.display({ + id: "lockedBounty", + header: () => <div className="text-right">Bounty</div>, cell: ({ row }) => { - const { txHash } = row.original - const blockExplorerUrl = chain?.blockExplorers?.default.url + const { lockedBounty } = row.original return ( - <div className="w-full h-full flex justify-end"> - <BlockExplorer - address={txHash} - blockExplorerUrl={blockExplorerUrl} - description={false} - /> - </div> + <div className="w-full h-full flex justify-end">{lockedBounty}</div> ) }, }), diff --git a/app/strategies/[address]/_components/overview/overview.tsx b/app/strategies/[address]/_components/overview/overview.tsx index a4a0d716..430cf1fa 100644 --- a/app/strategies/[address]/_components/overview/overview.tsx +++ b/app/strategies/[address]/_components/overview/overview.tsx @@ -6,6 +6,7 @@ export default function Overview() { <div> <PriceRangeInfos /> <div className="py-10"> + {/* TODO:add SCROll */} <OffersTable /> </div> </div> diff --git a/app/strategies/[address]/_components/overview/table/offers-table.tsx b/app/strategies/[address]/_components/overview/table/offers-table.tsx index 4462de9d..2b07e9d2 100644 --- a/app/strategies/[address]/_components/overview/table/offers-table.tsx +++ b/app/strategies/[address]/_components/overview/table/offers-table.tsx @@ -1,11 +1,17 @@ +import React from "react" + +import RefillOfferDialog from "@/app/strategies/(shared)/_components/refill-dialog" import { DataTable } from "@/components/ui/data-table/data-table" import { useHoveredOfferStore } from "@/stores/hovered-offer.store" import useKandel from "../../../_providers/kandel-strategy" import { MergedOffer, type MergedOffers } from "../../../_utils/inventory" -import RefillRow from "./refill-row" import { useOffersTable } from "./use-offers-table" export default function OffersTable() { + const [refillOffer, setRefillOffer] = React.useState< + MergedOffer | undefined + >() + const { mergedOffers, strategyQuery, strategyStatusQuery } = useKandel() const { hoveredOffer, setHoveredOffer } = useHoveredOfferStore() @@ -17,19 +23,25 @@ export default function OffersTable() { const isError = strategyQuery.isError || strategyStatusQuery.isError return ( - <DataTable - table={table} - isLoading={isLoading} - isError={isError} - isRowHighlighted={(offer) => - offer.offerId === hoveredOffer?.offerId && - offer.offerType === hoveredOffer?.offerType - } - onRowHover={(offer) => setHoveredOffer(offer as MergedOffer)} - renderExtraRow={(row) => { - if (row.original.live) return null - return <RefillRow row={row} /> - }} - /> + <> + <DataTable + table={table} + isLoading={isLoading} + isError={isError} + isRowHighlighted={(offer) => + offer.offerId === hoveredOffer?.offerId && + offer.offerType === hoveredOffer?.offerType + } + onRowHover={(offer) => setHoveredOffer(offer as MergedOffer)} + // renderExtraRow={(row) => { + // if (row.original.live) return null + // return <RefillRow row={row} openRefill={setRefillOffer} /> + // }} + /> + <RefillOfferDialog + offer={refillOffer} + onClose={() => setRefillOffer(undefined)} + /> + </> ) } diff --git a/app/strategies/[address]/_components/overview/table/refill-row.tsx b/app/strategies/[address]/_components/overview/table/refill-row.tsx index 7f57831e..cd93757a 100644 --- a/app/strategies/[address]/_components/overview/table/refill-row.tsx +++ b/app/strategies/[address]/_components/overview/table/refill-row.tsx @@ -3,6 +3,7 @@ import { Row } from "@tanstack/react-table" import Big from "big.js" import { Info } from "lucide-react" +import { useRefillOffer } from "@/app/strategies/(shared)/_hooks/use-refill-offer" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { TableCell } from "@/components/ui/table" @@ -11,12 +12,21 @@ import { useRefillRequirements } from "../../../_hooks/use-refill-requirements" import useKandel from "../../../_providers/kandel-strategy" import { MergedOffer } from "../../../_utils/inventory" -export default function RefillRow({ row }: { row: Row<MergedOffer> }) { +export default function RefillRow({ + row, + openRefill, +}: { + row: Row<MergedOffer> + openRefill: (offer: MergedOffer) => void +}) { const { strategyStatusQuery } = useKandel() const { data } = useRefillRequirements({ offer: row.original, }) - const { base, quote } = strategyStatusQuery.data?.market ?? {} + + const { market } = strategyStatusQuery.data ?? {} + const { base, quote } = market ?? {} + const refill = useRefillOffer({ offer: row.original }) return ( <tr className="relative hidden md:table-row"> @@ -43,7 +53,13 @@ export default function RefillRow({ row }: { row: Row<MergedOffer> }) { {/* <LabelValueItem label="Min quote" value={Big(0)} token={quote} /> */} </div> {/* TODO: implement re-fill */} - <Button size={"sm"} className="px-5"> + <Button + size={"sm"} + className="px-5" + onClick={() => openRefill(row.original)} + loading={refill.isPending} + disabled={refill.isPending} + > Re-fill </Button> </div> diff --git a/app/strategies/[address]/_components/parameters/dialogs/bounty.tsx b/app/strategies/[address]/_components/parameters/dialogs/bounty.tsx index 494d9114..bbf97e16 100644 --- a/app/strategies/[address]/_components/parameters/dialogs/bounty.tsx +++ b/app/strategies/[address]/_components/parameters/dialogs/bounty.tsx @@ -11,12 +11,6 @@ import { EnhancedNumericInput } from "@/components/token-input" import { Text } from "@/components/typography/text" import { Title } from "@/components/typography/title" import { Button } from "@/components/ui/button" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" import { useStep } from "@/hooks/use-step" import { TooltipInfo } from "@/svgs" import { cn } from "@/utils" @@ -165,26 +159,17 @@ export function Bounty({ open, onClose }: Props) { className="space-x-3 flex items-center " > Bounty + <TooltipInfo> + <Text className="text-wrap"> + Native token used to compensate the taker in case your order + fails. + </Text> + <Text> + Order might fail in case the liquidity is not on the origin + source anymore. + </Text> + </TooltipInfo> - - - - - - - - - - Native token used to compensate the taker in case your order - fails. - - - Order might fail in case the liquidity is not on the origin - source anymore. - - - -
diff --git a/app/strategies/[address]/_components/parameters/dialogs/close.tsx b/app/strategies/[address]/_components/parameters/dialogs/close.tsx index b55dfca3..25bede26 100644 --- a/app/strategies/[address]/_components/parameters/dialogs/close.tsx +++ b/app/strategies/[address]/_components/parameters/dialogs/close.tsx @@ -1,26 +1,23 @@ import { AlertCircle } from "lucide-react" -import useKandel from "@/app/strategies/(list)/_providers/kandel-strategies" import Dialog from "@/components/dialogs/dialog" import { Text } from "@/components/typography/text" import { Title } from "@/components/typography/title" import { Button } from "@/components/ui/button" -import useMarket from "@/providers/market" import { Close } from "@/svgs" import { useCloseStrategy } from "../../../_hooks/use-close-strategy" -import useKandelContext from "../../../_providers/kandel-strategy" type Props = { isOpen: boolean + strategyAddress: string onClose: () => void } -export default function CloseDialog({ isOpen, onClose }: Props) { - const { getMarketFromAddresses } = useMarket() - const { strategyQuery } = useKandelContext() - const { kandelStrategies } = useKandel() - const { base, quote, address: strategyAddress } = strategyQuery.data ?? {} - +export default function CloseStrategyDialog({ + isOpen, + onClose, + strategyAddress, +}: Props) { const closeStrategy = useCloseStrategy({ strategyAddress }) return ( @@ -35,30 +32,19 @@ export default function CloseDialog({ isOpen, onClose }: Props) {
- Are you sure you want to close this strategy + Are you sure you want to close this strategy ? You can re-open it at any time. - - Funds will be withdrawn from the strategy and returned to your - wallet. -
@@ -67,6 +53,7 @@ export default function CloseDialog({ isOpen, onClose }: Props) { className="w-full" variant={"secondary"} disabled={closeStrategy.isPending} + size={"lg"} > No, cancel diff --git a/app/strategies/[address]/_components/parameters/dialogs/deposit.tsx b/app/strategies/[address]/_components/parameters/dialogs/deposit.tsx index 4fe98202..4c99537f 100644 --- a/app/strategies/[address]/_components/parameters/dialogs/deposit.tsx +++ b/app/strategies/[address]/_components/parameters/dialogs/deposit.tsx @@ -61,29 +61,33 @@ export function Deposit({ togglePublish, open, onClose }: Props) {
setBaseAmount(baseBalance?.toString() || ""), - text: "MAX", + onClick: () => setBaseAmount(baseBalance as string), }} + value={baseAmount} label={`${market?.base.symbol} amount`} showBalance token={market?.base} onChange={(e) => setBaseAmount(e.target.value)} error={ - Number(baseAmount) > Number(baseBalance) ? "Invalid amount" : "" + Number(baseAmount) > Number(baseBalance) + ? "Insufficient balance" + : "" } /> setQuoteAmount(quoteBalance?.toString() || ""), - text: "MAX", + onClick: () => setQuoteAmount(quoteBalance as string), }} label={`${market?.quote.symbol} amount`} showBalance token={market?.quote} onChange={(e) => setQuoteAmount(e.target.value)} error={ - Number(quoteAmount) > Number(quoteBalance) ? "Invalid amount" : "" + Number(quoteAmount) > Number(quoteBalance) + ? "Insufficient balance" + : "" } />
@@ -98,6 +102,7 @@ export function Deposit({ togglePublish, open, onClose }: Props) { } onClick={goToNextStep} className="w-full flex items-center justify-center !mt-6" + size={"lg"} > Proceed{" "}
Deposit
{ + setBaseAmount("") + setQuoteAmount("") + reset() + onClose() + } + return ( <> - + setBaseAmount(unPublishedBase), - text: "MAX", }} value={baseAmount} label={`${market?.base.symbol} amount`} @@ -83,7 +76,7 @@ export function Publish({ open, onClose }: Props) { onChange={(e) => setBaseAmount(e.target.value)} error={ Number(baseAmount) > Number(unPublishedBase) - ? "Invalid amount" + ? "Insufficient balance" : "" } /> @@ -91,7 +84,6 @@ export function Publish({ open, onClose }: Props) { <EnhancedNumericInput balanceAction={{ onClick: () => setQuoteAmount(unPublishedQuote), - text: "MAX", }} value={quoteAmount} label={`${market?.quote.symbol} amount`} @@ -102,7 +94,7 @@ export function Publish({ open, onClose }: Props) { onChange={(e) => setQuoteAmount(e.target.value)} error={ Number(quoteAmount) > Number(unPublishedQuote) - ? "Invalid amount" + ? "Insufficient balance" : "" } /> @@ -119,6 +111,7 @@ export function Publish({ open, onClose }: Props) { } onClick={goToNextStep} className="w-full flex items-center justify-center !mt-6" + size={"lg"} > Proceed{" "} <div @@ -183,6 +176,7 @@ export function Publish({ open, onClose }: Props) { }, }) } + size={"lg"} className="w-full flex items-center justify-center !mt-6" > Publish @@ -208,6 +202,13 @@ export function Publish({ open, onClose }: Props) { } }) + const closeDialog = () => { + setBaseAmount("") + setQuoteAmount("") + reset() + onClose() + } + return ( <> <SuccessDialog @@ -216,7 +217,7 @@ export function Publish({ open, onClose }: Props) { onClose={togglePublishCompleted} /> - <Dialog open={!!open} onClose={onClose} showCloseButton={false}> + <Dialog open={!!open} onClose={closeDialog} showCloseButton={false}> <Dialog.Title className="text-xl text-left" close> <div className="flex space-x-2 items-center"> <Title @@ -225,23 +226,16 @@ export function Publish({ open, onClose }: Props) { className="space-x-3 flex items-center" > Publish - <TooltipProvider> - <Tooltip delayDuration={200} defaultOpen={false}> - <TooltipTrigger className="hover:opacity-80 transition-opacity"> - <TooltipInfo /> - </TooltipTrigger> - <TooltipContent> - <Text> - Funds are evenly distributed across the active strategy. - </Text> - <Link href={KANDEL_DOC_URL} target="_blank"> - <Caption className="text-primary underline"> - Learn more - </Caption> - </Link> - </TooltipContent> - </Tooltip> - </TooltipProvider> + <InfoTooltip> + <Text> + Funds are evenly distributed across the active strategy. + </Text> + <Link href={KANDEL_DOC_URL} target="_blank"> + <Caption className="text-primary underline"> + Learn more + </Caption> + </Link> + </InfoTooltip>
diff --git a/app/strategies/[address]/_components/parameters/dialogs/unpublish.tsx b/app/strategies/[address]/_components/parameters/dialogs/unpublish.tsx index 39691e26..51dec3a1 100644 --- a/app/strategies/[address]/_components/parameters/dialogs/unpublish.tsx +++ b/app/strategies/[address]/_components/parameters/dialogs/unpublish.tsx @@ -17,6 +17,7 @@ import { useStep } from "@/hooks/use-step" import { cn } from "@/utils" import useKandel from "../../../_providers/kandel-strategy" import { MergedOffers } from "../../../_utils/inventory" +import { useParameters } from "../hook/use-parameters" import { useUnPublish } from "../mutations/use-unpublish" import { SuccessDialog } from "./succes-dialog" @@ -40,40 +41,22 @@ export function UnPublish({ open, onClose }: Props) { quote: market?.quote.symbol, offers: strategyQuery.data?.offers, }) - const getUnpublishedBalances = async () => { - const asks = - await strategyStatusQuery.data?.stratInstance.getUnpublished("asks") - const bids = - await strategyStatusQuery.data?.stratInstance.getUnpublished("bids") - return { asks, bids } - } - - let steps = ["Set", "UnPublish"] + let steps = ["Set", "Unpublish"] const [currentStep, helpers] = useStep(steps.length) const { goToNextStep, reset } = helpers const [baseAmount, setBaseAmount] = React.useState("") const [quoteAmount, setQuoteAmount] = React.useState("") - const [upublishedBase, setUnpublishedBase] = React.useState("") - const [upublishedQuote, setUnpublishedQuote] = React.useState("") - - React.useEffect(() => { - const fetchUnpublishedBalances = async () => { - try { - const { asks, bids } = await getUnpublishedBalances() - if (!asks || !bids) return - - setUnpublishedBase(asks.toFixed(market?.base.decimals)) - setUnpublishedQuote(bids.toFixed(market?.quote.decimals)) - } catch (error) { - console.error("Error fetching unpublished balances:", error) - } - } + const { publishedBase, publishedQuote } = useParameters() - fetchUnpublishedBalances() - }, [strategyStatusQuery.data]) + const publishBaseFormatted = publishedBase.toFixed( + market?.base.displayedDecimals, + ) + const publishQuoteFormatted = publishedQuote.toFixed( + market?.base.displayedDecimals, + ) const publish = useUnPublish({ stratInstance: strategy?.stratInstance, @@ -87,38 +70,36 @@ export function UnPublish({ open, onClose }: Props) {
setBaseAmount(upublishedBase), - text: "MAX", + onClick: () => setBaseAmount(publishBaseFormatted), }} value={baseAmount} label={`${market?.base.symbol} amount`} - customBalance={upublishedBase} + customBalance={publishBaseFormatted} showBalance balanceLabel="Unpublished inventory" token={market?.base} onChange={(e) => setBaseAmount(e.target.value)} error={ - Number(baseAmount) > Number(upublishedBase) - ? "Invalid amount" + Number(baseAmount) > Number(publishedBase) + ? "Insufficient balance" : "" } /> setQuoteAmount(upublishedQuote), - text: "MAX", + onClick: () => setQuoteAmount(publishQuoteFormatted), }} value={quoteAmount} label={`${market?.quote.symbol} amount`} - customBalance={upublishedQuote} + customBalance={publishQuoteFormatted} showBalance balanceLabel="Unpublished inventory" token={market?.quote} onChange={(e) => setQuoteAmount(e.target.value)} error={ - Number(quoteAmount) > Number(upublishedQuote) - ? "Invalid amount" + Number(quoteAmount) > Number(publishQuoteFormatted) + ? "Insufficient balance" : "" } /> @@ -130,11 +111,12 @@ export function UnPublish({ open, onClose }: Props) { disabled={ !baseAmount || !quoteAmount || - Number(baseAmount) > Number(upublishedBase) || - Number(quoteAmount) > Number(upublishedQuote) + Number(baseAmount) > Number(publishedBase) || + Number(quoteAmount) > Number(publishedQuote) } onClick={goToNextStep} className="w-full flex items-center justify-center !mt-6" + size={"lg"} > Proceed{" "}
- Learn more + + Learn more +
@@ -200,6 +184,7 @@ export function UnPublish({ open, onClose }: Props) { }) } className="w-full flex items-center justify-center !mt-6" + size={"lg"} > UnPublish
@@ -240,7 +225,7 @@ export function UnPublish({ open, onClose }: Props) { variant={"header1"} className="space-x-3 flex items-center" > - UnPublish + Unpublish
diff --git a/app/strategies/[address]/_components/parameters/dialogs/withdraw.tsx b/app/strategies/[address]/_components/parameters/dialogs/withdraw.tsx index f6cc1679..2db051f1 100644 --- a/app/strategies/[address]/_components/parameters/dialogs/withdraw.tsx +++ b/app/strategies/[address]/_components/parameters/dialogs/withdraw.tsx @@ -7,22 +7,16 @@ import { useAccount } from "wagmi" import useStrategyStatus from "@/app/strategies/(shared)/_hooks/use-strategy-status" import { ApproveStep } from "@/app/strategies/new/_components/form/components/approve-step" import { Steps } from "@/app/strategies/new/_components/form/components/steps" -import { useApproveKandelStrategy } from "@/app/strategies/new/_hooks/use-approve-kandel-strategy" +import { useCreateKandelStrategy } from "@/app/strategies/new/_hooks/use-approve-kandel-strategy" import Dialog from "@/components/dialogs/dialog" +import InfoTooltip from "@/components/info-tooltip" import { EnhancedNumericInput } from "@/components/token-input" import { Caption } from "@/components/typography/caption" import { Text } from "@/components/typography/text" import { Title } from "@/components/typography/title" import { Button } from "@/components/ui/button" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" import { KANDEL_DOC_URL } from "@/constants/docs" import { useStep } from "@/hooks/use-step" -import { TooltipInfo } from "@/svgs" import { cn } from "@/utils" import { shortenAddress } from "@/utils/wallet" import Link from "next/link" @@ -93,7 +87,7 @@ export function Withdraw({ open, onClose }: Props) { fetchUnpublishedBalances() }, [strategyStatusQuery.data]) - const approve = useApproveKandelStrategy({ + const approve = useCreateKandelStrategy({ setKandelAddress: (address) => address, }) @@ -109,7 +103,6 @@ export function Withdraw({ open, onClose }: Props) { setBaseAmount(upublishedBase), - text: "MAX", }} value={baseAmount} label={`${market?.base.symbol} amount`} @@ -128,10 +121,10 @@ export function Withdraw({ open, onClose }: Props) { setQuoteAmount(upublishedQuote), - text: "MAX", }} value={quoteAmount} label={`${market?.quote.symbol} amount`} + customBalance={upublishedQuote} balanceLabel="Unpublished inventory" onChange={(e) => setQuoteAmount(e.target.value)} error={ @@ -155,6 +148,7 @@ export function Withdraw({ open, onClose }: Props) { } onClick={goToNextStep} className="w-full flex items-center justify-center !mt-6" + size={"lg"} > Proceed{" "}
{ - approve.mutate( - { - baseDeposit: baseAmount, - quoteDeposit: quoteAmount, - }, - { - onSuccess: goToNextStep, - }, - ) + approve.mutate(undefined, { + onSuccess: goToNextStep, + }) }} > Approve @@ -283,21 +272,12 @@ export function Withdraw({ open, onClose }: Props) { > Withdraw - - - - - - - Only unpublished funds are available to withdraw. - - - Learn more - - - - - + + Only unpublished funds are available to withdraw. + + Learn more + +
diff --git a/app/strategies/[address]/_components/parameters/hook/use-parameters.ts b/app/strategies/[address]/_components/parameters/hook/use-parameters.ts index 6dba8971..d0366b40 100644 --- a/app/strategies/[address]/_components/parameters/hook/use-parameters.ts +++ b/app/strategies/[address]/_components/parameters/hook/use-parameters.ts @@ -18,14 +18,8 @@ export const useParameters = () => { address, }) - const { - book, - market, - asksBalance, - bidsBalance, - offerStatuses, - stratInstance, - } = strategyStatusQuery.data ?? {} + const { book, market, offerStatuses, stratInstance } = + strategyStatusQuery.data ?? {} const { depositedBase, @@ -37,7 +31,7 @@ export const useParameters = () => { depositsAndWithdraws, } = strategyQuery.data ?? {} - const { maxPrice, minPrice, priceRatio } = offerStatuses ?? {} + const { maxPrice, minPrice } = offerStatuses ?? {} const asks = offers @@ -62,7 +56,7 @@ export const useParameters = () => { asks, bids, }) - .toFixed(nativeBalance?.decimals ?? 4) + .toFixed(nativeBalance?.decimals ?? 6) const publishedBase = getPublished(mergedOffers as MergedOffers, "asks") const publishedQuote = getPublished(mergedOffers as MergedOffers, "bids") @@ -88,7 +82,7 @@ export const useParameters = () => { (acc: Big, offer) => acc.add( offer.live && offer.offerType === offerType - ? Big(offer[key]) + ? Big(offer.gives) : Big(0), ), Big(0), @@ -98,7 +92,7 @@ export const useParameters = () => { const getUnpublishedBalances = async () => { const asks = await stratInstance?.getUnpublished("asks") const bids = await stratInstance?.getUnpublished("bids") - + // TODO: fixe the negative values return [asks, bids] } @@ -106,17 +100,17 @@ export const useParameters = () => { const fetchUnpublishedBalancesAndBounty = async () => { const [base, quote] = await getUnpublishedBalances() - if (!base || !quote || !asksBalance || !bidsBalance) return + if (!base || !quote) return - const { unallocatedBase, unallocatedQuote } = getUnallocatedInventory( - { base: asksBalance, quote: bidsBalance }, - { base: publishedBase, quote: publishedQuote }, - ) + // const { unallocatedBase, unallocatedQuote } = getUnallocatedInventory( + // { base: asksBalance, quote: bidsBalance }, + // { base: publishedBase, quote: publishedQuote }, + // ) setUnpublishedBase(base.toFixed(market?.base?.decimals)) setUnPublishedQuote(quote.toFixed(market?.base?.decimals)) - setUnallocatedBase(unallocatedBase.toFixed(market?.base?.decimals)) - setUnallocatedQuote(unallocatedQuote.toFixed(market?.quote?.decimals)) + // setUnallocatedBase(unallocatedBase.toFixed(market?.base?.decimals)) + // setUnallocatedQuote(unallocatedQuote.toFixed(market?.quote?.decimals)) } fetchUnpublishedBalancesAndBounty() @@ -129,12 +123,14 @@ export const useParameters = () => { currentParameter: { ...currentParameter, lockedBounty, + nativeSymbol: nativeBalance?.symbol, maxPrice, minPrice, - priceRatio, creationDate, strategyAddress, }, + publishedBase, + publishedQuote, unallocatedBase, unallocatedQuote, unPublishedBase, diff --git a/app/strategies/[address]/_components/parameters/mutations/use-deposit.ts b/app/strategies/[address]/_components/parameters/mutations/use-deposit.ts index a2561e26..6f77dcda 100644 --- a/app/strategies/[address]/_components/parameters/mutations/use-deposit.ts +++ b/app/strategies/[address]/_components/parameters/mutations/use-deposit.ts @@ -7,7 +7,7 @@ export function useDeposit({ volumes, }: { stratInstance?: GeometricKandelInstance - volumes: { baseAmount: string; quoteAmount: string } + volumes: { baseAmount?: string; quoteAmount?: string } }) { const queryClient = useQueryClient() diff --git a/app/strategies/[address]/_components/parameters/parameters.tsx b/app/strategies/[address]/_components/parameters/parameters.tsx index ebaec232..7ca8a332 100644 --- a/app/strategies/[address]/_components/parameters/parameters.tsx +++ b/app/strategies/[address]/_components/parameters/parameters.tsx @@ -1,23 +1,20 @@ "use client" -import { Info } from "lucide-react" import React from "react" +import InfoTooltip from "@/components/info-tooltip" import { Caption } from "@/components/typography/caption" import { Text } from "@/components/typography/text" import { Title } from "@/components/typography/title" import { Button } from "@/components/ui/button" -import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" +import Big from "big.js" +import { useAccount } from "wagmi" import PriceRangeInfos from "../shared/price-range-infos" import { Bounty } from "./dialogs/bounty" -import CloseDialog from "./dialogs/close" -import { Deposit } from "./dialogs/deposit" import { Publish } from "./dialogs/publish" -import { UnPublish } from "./dialogs/unpublish" import { Withdraw } from "./dialogs/withdraw" import { useParameters } from "./hook/use-parameters" -import { useInventoryTable } from "./table/use-inventory-table" const InfoLine = ({ title, @@ -39,16 +36,18 @@ const InfoBar = () => { return (
- - +
) @@ -67,12 +66,12 @@ const UnallocatedInventory = () => {
{/* Header */}
-
+
Unallocated inventory - + TODO:
- + {/* */} @@ -83,38 +82,40 @@ const UnallocatedInventory = () => {
{/* Table */} - +
- - - + + {base?.symbol} - {unallocatedBase} {base?.symbol} + {Big(Number(unallocatedBase)).toFixed(base?.displayedDecimals, 1)}{" "} + {base?.symbol} - - + {quote?.symbol} - {unallocatedQuote} {quote?.symbol} + {Big(Number(unallocatedQuote)).toFixed( + quote?.displayedAsPriceDecimals, + 1, + )}{" "} + {quote?.symbol} -
Asset Amount
{/* Dialogs */} - + /> */}
@@ -122,7 +123,7 @@ const UnallocatedInventory = () => { } const PublishedInventory = () => { - const { quote, base, depositedBase, depositedQuote } = useParameters() + const { quote, base, publishedBase, publishedQuote } = useParameters() const [unPublish, toggleUnpublish] = React.useReducer( (isOpen) => !isOpen, @@ -130,60 +131,60 @@ const PublishedInventory = () => { ) const [close, toggleClose] = React.useReducer((isOpen) => !isOpen, false) - const table = useInventoryTable({ - data: [ - { amount: "0.00000", asset: "WETH" }, - { amount: "0.0000", asset: "USDC" }, - ], - }) - return (
{/* Header */}
-
+
Published inventory - + TODO:
- - + {/* TODO: create dialog for add inventory */} + {/* */} + {/* */}
{/* Table */} - +
- - - + + {base?.symbol} - {depositedBase} {base?.symbol} + {publishedBase && + Big(Number(publishedBase)).toFixed( + base?.displayedDecimals, + 1, + )}{" "} + {base?.symbol} - - + {quote?.symbol} - {depositedQuote} {quote?.symbol} + {publishedQuote && + Big(Number(publishedQuote)).toFixed( + quote?.displayedAsPriceDecimals, + 1, + )}{" "} + {quote?.symbol} -
Asset Amount
- {/* */} {/* Dialogs */} - - + {/* */} + {/* */}
) } @@ -191,42 +192,39 @@ const PublishedInventory = () => { const BountyInventory = () => { const [bounty, toggleBounty] = React.useReducer((isOpen) => !isOpen, false) const { currentParameter } = useParameters() - - const table = useInventoryTable({ - data: [{ amount: "0.0000", asset: "USDC" }], - }) + const { chain } = useAccount() return (
{/* Header */}
-
+
Bounty - + TODO:
- + {/* */}
{/* Table */} - +
- - - MATIC - {currentParameter.lockedBounty} MATIC + + {chain?.nativeCurrency.symbol} + + {Big(currentParameter.lockedBounty ?? 0).toString()}{" "} + {chain?.nativeCurrency.symbol} + -
Asset Amount
- {/* */} {/* Dialogs */} @@ -245,7 +243,7 @@ export default function Parameters() { {/* Tables */}
- + {/* */}
diff --git a/app/strategies/[address]/_components/shared/information-banner.tsx b/app/strategies/[address]/_components/shared/information-banner.tsx index 2c6a73e4..fa0542a1 100644 --- a/app/strategies/[address]/_components/shared/information-banner.tsx +++ b/app/strategies/[address]/_components/shared/information-banner.tsx @@ -1,24 +1,31 @@ import { X } from "lucide-react" +import Link from "next/link" +import { useRouter } from "next/navigation" import React from "react" import { Title } from "@/components/typography/title" import { Button } from "@/components/ui/button" import { Info } from "@/svgs" import { cn } from "@/utils" -import Link from "next/link" import useKandel from "../../_providers/kandel-strategy" export default function InformationBanner() { - const { strategyStatusQuery, mergedOffers } = useKandel() const [bannerOpen, setBannerOpen] = React.useState(true) + const { push } = useRouter() + const { strategyStatusQuery, mergedOffers } = useKandel() + const { isOutOfRange } = strategyStatusQuery.data ?? {} - const { isOutOfRange, unexpectedDeadOffers } = strategyStatusQuery.data ?? {} const allOffersAreDead = !mergedOffers || mergedOffers?.length === 0 const isInactive = strategyStatusQuery.data?.status === "inactive" const isActive = strategyStatusQuery.data?.status === "active" - if (!strategyStatusQuery.data || strategyStatusQuery.isLoading || !bannerOpen) + if ( + !strategyStatusQuery.data || + strategyStatusQuery.isLoading || + !bannerOpen || + isActive // FIXME: check if we keep the information when offers are empty + ) return null return ( @@ -77,36 +84,26 @@ export default function InformationBanner() { )} {isActive && (
  • - We’ve notice empty offer in this strategy. You can refill it - bellow. + We’ve notice empty offer in this strategy. + {/* You can refill it + bellow. */}
  • )} - {isInactive && ( -
    - {/* TODO: plug button */} - - -
    - )} + Learn more + + +
    diff --git a/app/strategies/[address]/_components/shared/price-range-infos.tsx b/app/strategies/[address]/_components/shared/price-range-infos.tsx index a44074d2..025f034b 100644 --- a/app/strategies/[address]/_components/shared/price-range-infos.tsx +++ b/app/strategies/[address]/_components/shared/price-range-infos.tsx @@ -1,7 +1,7 @@ import { PriceRangeChart } from "@/app/strategies/new/_components/price-range/components/price-chart/price-range-chart" -import { AverageReturn } from "../../../(shared)/_components/average-return" import useKandel from "../../_providers/kandel-strategy" import { MergedOffers } from "../../_utils/inventory" +import { useParameters } from "../parameters/hook/use-parameters" import { LegendItem } from "./legend-item" import TotalInventory from "./total-inventory" import UnrealizedPnl from "./unrealized-pnl" @@ -14,20 +14,20 @@ export default function PriceRangeInfos() { quoteToken, mergedOffers, } = useKandel() - const { bidsBalance, asksBalance } = strategyStatusQuery.data ?? {} + const { publishedBase, publishedQuote } = useParameters() const bids = strategyStatusQuery.data?.book?.bids ?? [] const asks = strategyStatusQuery.data?.book?.asks ?? [] - const avgReturnPercentage = strategyQuery.data?.return as number | undefined + // const avgReturnPercentage = strategyQuery.data?.return as number | undefined const priceRange = !strategyQuery.isLoading ? ([Number(strategyQuery.data?.min), Number(strategyQuery.data?.max)] as [ number, number, ]) : undefined - const baseValue = `${asksBalance?.toFixed(baseToken?.displayedDecimals)} ${baseToken?.symbol}` - const quoteValue = `${bidsBalance?.toFixed(quoteToken?.displayedDecimals)} ${quoteToken?.symbol}` + const baseValue = `${publishedBase?.toFixed(baseToken?.displayedDecimals)} ${baseToken?.symbol}` + const quoteValue = `${publishedQuote?.toFixed(quoteToken?.displayedDecimals)} ${quoteToken?.symbol}` const isLoading = strategyStatusQuery.isLoading || !baseToken || !quoteToken const chartIsLoading = (strategyStatusQuery.isLoading && strategyQuery.isLoading) || @@ -37,7 +37,7 @@ export default function PriceRangeInfos() {
    - + {/* */} { + if (!strategyStatusQuery.data?.stratInstance) return + const [asksBalance, bidsBalance] = await Promise.all([ + strategyStatusQuery.data.stratInstance.getBalance("asks"), + strategyStatusQuery.data.stratInstance.getBalance("bids"), + ]) + + return { asksBalance, bidsBalance } + }, + initialData: { asksBalance: Big(0), bidsBalance: Big(0) }, + }).data ?? {} + + // const avgReturnPercentage = strategyQuery.data?.return as number | undefined const baseValue = `${asksBalance?.toFixed(baseToken?.displayedDecimals)} ${baseToken?.symbol}` const quoteValue = `${bidsBalance?.toFixed(quoteToken?.displayedDecimals)} ${quoteToken?.symbol}` @@ -18,7 +33,7 @@ export default function StratInfoBanner() {
    - + {/* */} - {"-"} + {"N/A"}
    ) diff --git a/app/strategies/[address]/_components/tabs.tsx b/app/strategies/[address]/_components/tabs.tsx index ddcbf1c3..3b9d1816 100644 --- a/app/strategies/[address]/_components/tabs.tsx +++ b/app/strategies/[address]/_components/tabs.tsx @@ -45,6 +45,8 @@ export default function Tabs( key={`${table}-content`} value={table} style={{ height: "var(--history-table-content-height)" }} + forceMount + className="data-[state=inactive]:hidden" >
    {renderElement(TABS_CONTENT[table])}
    diff --git a/app/strategies/[address]/_hooks/use-close-strategy.ts b/app/strategies/[address]/_hooks/use-close-strategy.ts index a24ae1ab..36f9e613 100644 --- a/app/strategies/[address]/_hooks/use-close-strategy.ts +++ b/app/strategies/[address]/_hooks/use-close-strategy.ts @@ -1,11 +1,14 @@ -import type { KandelStrategies, Market } from "@mangrovedao/mangrove.js" import { useMutation, useQueryClient } from "@tanstack/react-query" import { useRouter } from "next/navigation" import { toast } from "sonner" import { TRADE } from "@/app/trade/_constants/loading-keys" import { useResolveWhenBlockIsIndexed } from "@/hooks/use-resolve-when-block-is-indexed" +import { useTokenFromAddress } from "@/hooks/use-token-from-address" import { useLoadingStore } from "@/stores/loading.store" +import { Address } from "viem" +import useStrategyStatus from "../../(shared)/_hooks/use-strategy-status" +import { useStrategy } from "./use-strategy" type Props = { strategyAddress?: string @@ -13,40 +16,35 @@ type Props = { export function useCloseStrategy({ strategyAddress }: Props) { const router = useRouter() - const resolveWhenBlockIsIndexed = useResolveWhenBlockIsIndexed() const queryClient = useQueryClient() + const strategyQuery = useStrategy({ + strategyAddress: strategyAddress as string, + }) + const { data: baseToken } = useTokenFromAddress( + strategyQuery.data?.base as Address, + ) + const { data: quoteToken } = useTokenFromAddress( + strategyQuery.data?.quote as Address, + ) + const { data: strategy } = useStrategyStatus({ + address: strategyAddress, + base: baseToken?.symbol, + quote: quoteToken?.symbol, + offers: strategyQuery.data?.offers, + }) + + const resolveWhenBlockIsIndexed = useResolveWhenBlockIsIndexed() const [startLoading, stopLoading] = useLoadingStore((state) => [ state.startLoading, state.stopLoading, ]) return useMutation({ - mutationFn: async ({ - base, - quote, - kandelStrategies, - getMarketFromAddresses, - }: { - base?: string - quote?: string - kandelStrategies?: KandelStrategies - getMarketFromAddresses: ( - base: string, - quote: string, - ) => Promise | undefined - }) => { - if (!base || !quote) return - const market = await getMarketFromAddresses(base, quote) - - if (!market || !strategyAddress) return - const stratInstance = await kandelStrategies?.instance({ - address: strategyAddress, - market, - }) - - const txs = await stratInstance?.retractAndWithdraw() + mutationFn: async () => { + if (!strategyAddress || !strategy) return + const { stratInstance } = strategy + const txs = await stratInstance.retractAndWithdraw() const result = txs && (await Promise.all(txs.map((tx) => tx?.wait()))) - toast.success("Strategy closed with success") return { result } }, diff --git a/app/strategies/[address]/_providers/kandel-strategy.tsx b/app/strategies/[address]/_providers/kandel-strategy.tsx index 3b2102a3..ee7c47f2 100644 --- a/app/strategies/[address]/_providers/kandel-strategy.tsx +++ b/app/strategies/[address]/_providers/kandel-strategy.tsx @@ -36,9 +36,11 @@ const useKandelStrategyContext = () => { const sdkOffers = strategyStatusQuery.data?.offerStatuses const market = strategyStatusQuery.data?.market if (!(sdkOffers && indexerOffers && market)) return - //@ts-expect-error TODO: it's an error type from the indexer SDK + // @ts-expect-error TODO: it's an error type from the indexer SDK return getMergedOffers(sdkOffers, indexerOffers, market) }, [ + strategyQuery.dataUpdatedAt, + strategyStatusQuery.dataUpdatedAt, strategyQuery.data?.offers, strategyStatusQuery.data?.offerStatuses, strategyStatusQuery.data?.market, diff --git a/app/strategies/[address]/edit/_components/edit-strategy-dialog.tsx b/app/strategies/[address]/edit/_components/edit-strategy-dialog.tsx new file mode 100644 index 00000000..458e4099 --- /dev/null +++ b/app/strategies/[address]/edit/_components/edit-strategy-dialog.tsx @@ -0,0 +1,428 @@ +import { Token } from "@mangrovedao/mangrove.js" +import React from "react" +import { useAccount, useBalance } from "wagmi" + +import { useSpenderAddress } from "@/app/trade/_components/forms/hooks/use-spender-address" +import Dialog from "@/components/dialogs/dialog" +import { TokenPair } from "@/components/token-pair" +import { Text } from "@/components/typography/text" +import { Button, type ButtonProps } from "@/components/ui/button" +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { useInfiniteApproveToken } from "@/hooks/use-infinite-approve-token" +import { useIsTokenInfiniteAllowance } from "@/hooks/use-is-token-infinite-allowance" +import { useStep } from "@/hooks/use-step" +import useMangrove from "@/providers/mangrove" +import useMarket from "@/providers/market" +import { NewStratStore } from "../../../new/_stores/new-strat.store" +import useKandel from "../../_providers/kandel-strategy" +import { useEditKandelStrategy } from "../_hooks/use-edit-kandel-strategy" +import { useRetractOffers } from "../_hooks/use-retract-offers" + +import { ApproveStep } from "@/app/trade/_components/forms/components/approve-step" +import { Steps } from "./form/components/steps" + +type StrategyDetails = Omit< + NewStratStore, + "isChangingFrom" | "globalError" | "errors" | "priceRange" +> & { onAave?: boolean; riskAppetite?: string; priceRange?: [number, number] } + +type Props = { + strategy?: StrategyDetails & { hasLiveOffers?: boolean } + isOpen: boolean + onClose: () => void +} + +const btnProps: ButtonProps = { + rightIcon: true, + className: "w-full", + size: "lg", +} + +export default function EditStrategyDialog({ + isOpen, + onClose, + strategy, +}: Props) { + const { address } = useAccount() + const { market } = useMarket() + const { mangrove } = useMangrove() + const { base: baseToken, quote: quoteToken } = market ?? {} + + const { data: nativeBalance } = useBalance({ + address, + }) + const { strategyQuery } = useKandel() + const kandelAddress = strategyQuery.data?.address + + const approveBaseToken = useInfiniteApproveToken() + const approveQuoteToken = useInfiniteApproveToken() + + const { mutate: retractOffers, isPending: isRetractingOffers } = + useRetractOffers({ kandelAddress }) + + const { mutate: editKandelStrategy, isPending: isEditingKandelStrategy } = + useEditKandelStrategy() + + const logics = mangrove ? Object.values(mangrove.logics) : [] + + const baseLogic = logics.find((logic) => logic?.id === strategy?.sendFrom) + const quoteLogic = logics.find((logic) => logic?.id === strategy?.receiveTo) + + const { data: spender } = useSpenderAddress("kandel") + + const { data: baseTokenApproved } = useIsTokenInfiniteAllowance( + baseToken, + spender, + baseLogic, + ) + + const { data: quoteTokenApproved } = useIsTokenInfiniteAllowance( + baseToken, + spender, + quoteLogic, + ) + + let steps = [ + "Summary", + strategy?.hasLiveOffers ? "Reset strategy" : "", + // TODO: apply liquidity sourcing with setLogics + // TODO: if sendFrom v3 logic selected then it'll the same it the other side for receive + // TODO: if erc721 approval, add select field with available nft ids then nft.approveForAll + !baseTokenApproved ? `Approve ${baseToken?.symbol}` : "", + !quoteTokenApproved ? `Approve ${quoteToken?.symbol}` : "", + "Publish", + ].filter(Boolean) + + const [currentStep, helpers] = useStep(steps.length) + const { goToNextStep, reset } = helpers + const stepInfos = [ + { + body: ( + + ), + button: ( + + ), + }, + + !baseTokenApproved && { + body: ( +
    + +
    + ), + button: ( + + ), + }, + !quoteTokenApproved && { + body: ( +
    + +
    + ), + button: ( + + ), + }, + strategy?.hasLiveOffers && { + body: ( +
    +
    +

    Reset strategy

    +

    + By granting permission, you are allowing the following contract to + reset this strategy. +

    +
    + ), + button: ( + + ), + }, + + { + body: ( + + ), + button: ( + + ), + }, + ] + .filter(Boolean) + .map((stepInfo, i) => { + return { + ...stepInfo, + title: steps[i], + } + }) + + const isDialogOpenRef = React.useRef(false) + React.useEffect(() => { + isDialogOpenRef.current = !!isOpen + + return () => { + isDialogOpenRef.current = false + } + }, [isOpen]) + + return ( + { + reset() + onClose() + }} + showCloseButton={false} + > + + Edit Strategy + + + + + + + {stepInfos[currentStep - 1]?.body ?? undefined} + + + {stepInfos[currentStep - 1]?.button} + + ) +} + +const SummaryLine = ({ + title, + value, +}: { + title?: string + value?: React.ReactNode +}) => { + return ( +
    + {title} + {value} +
    + ) +} + +const Summary = ({ + strategy, + baseToken, + quoteToken, + nativeBalance, +}: { + strategy?: StrategyDetails + baseToken?: Token + quoteToken?: Token + nativeBalance?: string +}) => { + const { + baseDeposit, + quoteDeposit, + numberOfOffers, + stepSize, + bountyDeposit, + priceRange, + riskAppetite, + } = strategy ?? {} + + const [minPrice, maxPrice] = priceRange ?? [] + + return ( +
    +
    + + + + + {false ? "Aave" : "Wallet"}} + /> + + {riskAppetite?.toUpperCase()}} + /> + + + + {Number(baseDeposit).toFixed(baseToken?.displayedDecimals) || 0} + + {baseToken?.symbol} +
    + } + /> + + + + {Number(quoteDeposit).toFixed(quoteToken?.displayedDecimals) || + 0} + + + {quoteToken?.symbol} + +
    + } + /> +
    + +
    + + {minPrice?.toFixed(quoteToken?.displayedDecimals)} + + {quoteToken?.symbol} + +
    + } + /> + + + {maxPrice?.toFixed(quoteToken?.displayedDecimals)} + + {quoteToken?.symbol} + +
    + } + /> +
    + +
    + {numberOfOffers}} + /> + {stepSize}} /> +
    + +
    + + {bountyDeposit} + {nativeBalance} +
    + } + /> +
    +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/fieldset.tsx b/app/strategies/[address]/edit/_components/fieldset.tsx new file mode 100644 index 00000000..7984f81d --- /dev/null +++ b/app/strategies/[address]/edit/_components/fieldset.tsx @@ -0,0 +1,21 @@ +import { Title } from "@/components/typography/title" +import { cn } from "@/utils" +import React from "react" + +type Props = React.ComponentProps<"fieldset"> & { + legend: React.ReactNode +} + +export function Fieldset({ className, legend, children, ...props }: Props) { + return ( +
    + + {legend} + + {children} +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/form/components/approve-step.tsx b/app/strategies/[address]/edit/_components/form/components/approve-step.tsx new file mode 100644 index 00000000..a8df9add --- /dev/null +++ b/app/strategies/[address]/edit/_components/form/components/approve-step.tsx @@ -0,0 +1,53 @@ +import { TokenIcon } from "@/components/token-icon" +import { TokenPair } from "@/components/token-pair" +import { Token } from "@mangrovedao/mangrove.js" + +type Props = { + baseToken?: Token + baseDeposit?: string + quoteToken?: Token + quoteDeposit?: string +} + +export function ApproveStep({ + baseToken, + baseDeposit, + quoteToken, + quoteDeposit, +}: Props) { + const isFullySided = Number(baseDeposit) > 0 && Number(quoteDeposit) > 0 + const isOnlyBase = Number(baseDeposit) > 0 + + const tokens = isFullySided + ? `${baseToken?.symbol}/${quoteToken?.symbol}` + : isOnlyBase + ? baseToken?.symbol + : quoteToken?.symbol + + return ( +
    +
    + {isFullySided ? ( + + ) : ( + + )} +
    +

    + Allow Mangrove to access your {tokens}? +

    +

    + By granting permission, you are allowing the following contract to + access your funds. +

    +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/form/components/minimum-recommended.tsx b/app/strategies/[address]/edit/_components/form/components/minimum-recommended.tsx new file mode 100644 index 00000000..a8336058 --- /dev/null +++ b/app/strategies/[address]/edit/_components/form/components/minimum-recommended.tsx @@ -0,0 +1,44 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { Token } from "@mangrovedao/mangrove.js" +import Big, { BigSource } from "big.js" + +type Props = { + loading?: boolean + token?: Token | string + value?: BigSource + action: { + onClick: (min: string) => void + text: string + } +} + +export function MinimumRecommended({ + loading = false, + value, + token, + action, +}: Props) { + const tokenSymbol = typeof token === "string" ? token : token?.symbol + const decimals = typeof token === "string" ? 6 : token?.displayedDecimals + + return ( +
    + Min recommended + {loading || !value ? ( + + ) : ( + + + {Big(value).toFixed(decimals)} {tokenSymbol} + + + + )} +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/form/components/must-be-at-least-info.tsx b/app/strategies/[address]/edit/_components/form/components/must-be-at-least-info.tsx new file mode 100644 index 00000000..545ad0cc --- /dev/null +++ b/app/strategies/[address]/edit/_components/form/components/must-be-at-least-info.tsx @@ -0,0 +1,20 @@ +type Props = { + min: number + onMinClicked: (min: string) => void +} + +export function MustBeAtLeastInfo({ onMinClicked, min }: Props) { + return ( +
    + + Must be at least {min} + + +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/form/components/steps.tsx b/app/strategies/[address]/edit/_components/form/components/steps.tsx new file mode 100644 index 00000000..f0f58a2e --- /dev/null +++ b/app/strategies/[address]/edit/_components/form/components/steps.tsx @@ -0,0 +1,49 @@ +import { cn } from "@/utils" + +type StepsProps = { + currentStep: number + steps?: string[] +} + +export function Steps({ currentStep = 1, steps }: StepsProps) { + return ( +
    + {steps?.map((value, i) => ( + = i + 1}> + {value} + + ))} +
    + ) +} + +type StepProps = { + children: React.ReactNode + number: number + active?: boolean +} + +function Step({ children, number, active = false }: StepProps) { + if (!children) return + return ( +
    +
    +
    +
    Step {number}
    +
    + {children} +
    +
    +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/form/form.tsx b/app/strategies/[address]/edit/_components/form/form.tsx new file mode 100644 index 00000000..82c351d0 --- /dev/null +++ b/app/strategies/[address]/edit/_components/form/form.tsx @@ -0,0 +1,272 @@ +"use client" + +import { TokenBalance } from "@/components/stateful/token-balance/token-balance" +import { EnhancedNumericInput } from "@/components/token-input" +import { Skeleton } from "@/components/ui/skeleton" +import { cn } from "@/utils" +import { Fieldset } from "../fieldset" +import { MinimumRecommended } from "./components/minimum-recommended" +import { MustBeAtLeastInfo } from "./components/must-be-at-least-info" +import useForm, { MIN_NUMBER_OF_OFFERS, MIN_STEP_SIZE } from "./use-form" + +export function Form({ className }: { className?: string }) { + const { + baseToken, + quoteToken, + requiredBase, + requiredQuote, + requiredBounty, + baseDeposit, + quoteDeposit, + fieldsDisabled, + errors, + sendFrom, + receiveTo, + handleBaseDepositChange, + handleQuoteDepositChange, + kandelRequirementsQuery, + isChangingFrom, + numberOfOffers, + handleNumberOfOffersChange, + stepSize, + handleStepSizeChange, + nativeBalance, + bountyDeposit, + handleBountyDepositChange, + handleSendFromChange, + handleReceiveToChange, + logics, + } = useForm() + + if (!baseToken || !quoteToken) + return ( +
    + +
    + ) + + return ( +
    { + e.preventDefault() + }} + > + {/*
    +
    +
    + + + +
    + +
    + + + +
    +
    +
    */} + +
    +
    + + + requiredBase && + handleBaseDepositChange(requiredBase.toString()), + text: "Update", + }} + loading={ + kandelRequirementsQuery.status !== "success" || fieldsDisabled + } + /> + + +
    +
    + + + + requiredQuote && + handleQuoteDepositChange(requiredQuote.toString()), + text: "Update", + }} + loading={ + kandelRequirementsQuery.status !== "success" || fieldsDisabled + } + /> + +
    +
    + +
    +
    + + +
    + +
    + + +
    +
    + +
    +
    + + + + +
    +
    +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/form/use-form.ts b/app/strategies/[address]/edit/_components/form/use-form.ts new file mode 100644 index 00000000..c7b3515f --- /dev/null +++ b/app/strategies/[address]/edit/_components/form/use-form.ts @@ -0,0 +1,344 @@ +import React from "react" +import { useDebounce } from "usehooks-ts" +import { useAccount, useBalance } from "wagmi" + +import { useTokenBalance } from "@/hooks/use-token-balance" +import useMangrove from "@/providers/mangrove" +import useMarket from "@/providers/market" +import { getErrorMessage } from "@/utils/errors" +import Big from "big.js" +import { + ChangingFrom, + useNewStratStore, +} from "../../../../new/_stores/new-strat.store" +import useKandel from "../../../_providers/kandel-strategy" +import { useKandelRequirements } from "../../_hooks/use-kandel-requirements" + +export const MIN_NUMBER_OF_OFFERS = 1 +export const MIN_STEP_SIZE = 1 + +export default function useForm() { + const { address } = useAccount() + const { mangrove } = useMangrove() + const { market } = useMarket() + const baseToken = market?.base + const quoteToken = market?.quote + const baseBalance = useTokenBalance(baseToken) + const quoteBalance = useTokenBalance(quoteToken) + const { data: nativeBalance } = useBalance({ + address, + }) + + const logics = mangrove?.getLogicsList().map((item) => { + if (item.id.includes("simple")) { + return { ...item, id: "Wallet" } + } else { + return item + } + }) + + const { strategyStatusQuery, mergedOffers, strategyQuery } = useKandel() + const { depositedBase, depositedQuote, currentParameter, offers } = + strategyQuery.data ?? {} + + const asksOffers = offers?.filter((item) => item.offerType === "asks") + const bidsOffers = offers?.filter((item) => item.offerType === "bids") + + const baseAmountDeposited = asksOffers?.reduce((acc, curr) => { + return acc.add(Big(curr.gives)) + }, Big(0)) + + const quoteAmountDeposited = bidsOffers?.reduce((acc, curr) => { + return acc.add(Big(curr.gives)) + }, Big(0)) + + const asks = + offers + ?.filter((item) => item.offerType === "asks") + .map(({ gasbase, gasreq, gasprice }) => ({ + gasbase: Number(gasbase || 0), + gasreq: Number(gasreq || 0), + gasprice: Number(gasprice || 0), + })) || [] + + const bids = + offers + ?.filter((item) => item.offerType === "bids") + .map(({ gasbase, gasreq, gasprice }) => ({ + gasbase: Number(gasbase || 0), + gasreq: Number(gasreq || 0), + gasprice: Number(gasprice || 0), + })) || [] + + const lockedBounty = strategyStatusQuery.data?.stratInstance + ?.getLockedProvisionFromOffers({ + asks, + bids, + }) + .toFixed(nativeBalance?.decimals ?? 6) + + React.useEffect(() => { + if (strategyQuery.data?.offers.some((x) => x.live)) { + setBaseDeposit( + baseAmountDeposited?.toFixed(baseToken?.displayedDecimals) || "0", + ) + setQuoteDeposit( + quoteAmountDeposited?.toFixed(quoteToken?.displayedDecimals) || "0", + ) + setNumberOfOffers( + (Number(currentParameter?.length) - 1).toString() || "0", + ) + setStepSize(currentParameter?.stepSize || "0") + setBountyDeposit(Number(lockedBounty).toFixed(6) || "0") + // setDistribution() + } + }, [strategyQuery.data?.offers, strategyStatusQuery.data?.offerStatuses]) + + const { + priceRange: [minPrice, maxPrice], + setGlobalError, + errors, + setErrors, + baseDeposit, + quoteDeposit, + numberOfOffers, + stepSize, + bountyDeposit, + isChangingFrom, + sendFrom, + receiveTo, + setBaseDeposit, + setQuoteDeposit, + setNumberOfOffers, + setStepSize, + setBountyDeposit, + setIsChangingFrom, + setDistribution, + setSendFrom, + setReceiveTo, + } = useNewStratStore() + const debouncedStepSize = useDebounce(stepSize, 300) + const debouncedNumberOfOffers = useDebounce(numberOfOffers, 300) + const fieldsDisabled = !(minPrice && maxPrice) + + const kandelRequirementsQuery = useKandelRequirements({ + onAave: false, + minPrice, + maxPrice, + availableBase: baseDeposit, + availableQuote: quoteDeposit, + stepSize: debouncedStepSize, + numberOfOffers: debouncedNumberOfOffers, + isChangingFrom, + }) + + const { + requiredBase, + requiredQuote, + requiredBounty, + offersWithPrices, + pricePoints: points, + distribution, + } = kandelRequirementsQuery.data || {} + + // I need the distribution to be set in the store to share it with the price range component + React.useEffect(() => { + setDistribution(distribution) + }, [distribution]) + + const setOffersWithPrices = useNewStratStore( + (store) => store.setOffersWithPrices, + ) + + // if kandelRequirementsQuery has error + React.useEffect(() => { + if (kandelRequirementsQuery.error) { + setGlobalError(getErrorMessage(kandelRequirementsQuery.error)) + return + } + setGlobalError(undefined) + }, [kandelRequirementsQuery.error]) + + React.useEffect(() => { + if ( + isChangingFrom === "numberOfOffers" || + !points || + Number(numberOfOffers) === points - 1 + ) + return + setNumberOfOffers(points.toString()) + }, [points]) + + React.useEffect(() => { + setOffersWithPrices(offersWithPrices) + }, [offersWithPrices]) + + const handleFieldChange = (field: ChangingFrom) => { + setIsChangingFrom(field) + } + + const handleBaseDepositChange = ( + e: React.ChangeEvent | string, + ) => { + handleFieldChange("baseDeposit") + const value = typeof e === "string" ? e : e.target.value + setBaseDeposit(value) + } + + const handleSendFromChange = ( + e: React.ChangeEvent | string, + ) => { + handleFieldChange("sendFrom") + const value = typeof e === "string" ? e : e.target.value + setSendFrom(value) + } + + const handleReceiveToChange = ( + e: React.ChangeEvent | string, + ) => { + handleFieldChange("receiveTo") + const value = typeof e === "string" ? e : e.target.value + setReceiveTo(value) + } + + const handleQuoteDepositChange = ( + e: React.ChangeEvent | string, + ) => { + handleFieldChange("quoteDeposit") + const value = typeof e === "string" ? e : e.target.value + setQuoteDeposit(value) + } + + const handleNumberOfOffersChange = ( + e: React.ChangeEvent | string, + ) => { + handleFieldChange("numberOfOffers") + const value = typeof e === "string" ? e : e.target.value + setNumberOfOffers(value) + } + + const handleStepSizeChange = ( + e: React.ChangeEvent | string, + ) => { + handleFieldChange("stepSize") + const value = typeof e === "string" ? e : e.target.value + setStepSize(value) + } + + const handleBountyDepositChange = ( + e: React.ChangeEvent | string, + ) => { + handleFieldChange("bountyDeposit") + const value = typeof e === "string" ? e : e.target.value + setBountyDeposit(value) + } + + React.useEffect(() => { + const newErrors = { ...errors } + + // Base Deposit Validation + if (Number(baseDeposit) > Number(baseBalance.formatted) && baseDeposit) { + newErrors.baseDeposit = + "Base deposit cannot be greater than wallet balance" + } else if (requiredBase?.gt(0) && Number(baseDeposit) === 0) { + newErrors.baseDeposit = "Base deposit must be greater than 0" + } else if ( + requiredBase?.gt(0) && + Number(requiredBase) > Number(baseDeposit) + ) { + newErrors.baseDeposit = "Base deposit must be uptated" + } else { + delete newErrors.baseDeposit + } + + // Quote Deposit Validation + if (Number(quoteDeposit) > Number(quoteBalance.formatted) && quoteDeposit) { + newErrors.quoteDeposit = + "Quote deposit cannot be greater than wallet balance" + } else if (requiredQuote?.gt(0) && Number(quoteDeposit) === 0) { + newErrors.quoteDeposit = "Quote deposit must be greater than 0" + } else if ( + requiredQuote?.gt(0) && + Number(requiredQuote) > Number(quoteDeposit) + ) { + newErrors.quoteDeposit = "Quote deposit must updated" + } else { + delete newErrors.quoteDeposit + } + + if ( + Number(numberOfOffers) < Number(MIN_NUMBER_OF_OFFERS) && + numberOfOffers + ) { + newErrors.numberOfOffers = "Number of offers must be at least 1" + } else { + delete newErrors.numberOfOffers + } + + if ( + (Number(stepSize) < Number(MIN_STEP_SIZE) || + Number(stepSize) >= Number(numberOfOffers) + 1) && + stepSize + ) { + newErrors.stepSize = + "Step size must be at least 1 and inferior or equal to number of offers" + } else { + delete newErrors.stepSize + } + + if ( + Number(bountyDeposit) > Number(nativeBalance?.formatted) && + bountyDeposit + ) { + newErrors.bountyDeposit = + "Bounty deposit cannot be greater than wallet balance" + } else if (requiredBounty?.gt(0) && Number(bountyDeposit) === 0) { + newErrors.bountyDeposit = "Bounty deposit must be greater than 0" + } else if ( + bountyDeposit && + Number(requiredBounty) > Number(bountyDeposit) + ) { + newErrors.bountyDeposit = "Bounty deposit must be updated" + } else { + delete newErrors.bountyDeposit + } + + setErrors(newErrors) + }, [ + baseDeposit, + quoteDeposit, + numberOfOffers, + stepSize, + bountyDeposit, + requiredBase, + requiredQuote, + ]) + + return { + baseToken, + quoteToken, + requiredBase, + requiredQuote, + requiredBounty, + isChangingFrom, + numberOfOffers, + baseDeposit, + quoteDeposit, + nativeBalance, + bountyDeposit, + fieldsDisabled, + errors, + kandelRequirementsQuery, + stepSize, + sendFrom, + receiveTo, + logics, + handleBaseDepositChange, + handleQuoteDepositChange, + handleNumberOfOffersChange, + handleSendFromChange, + handleReceiveToChange, + handleStepSizeChange, + handleBountyDepositChange, + } +} diff --git a/app/strategies/[address]/edit/_components/info-bar.tsx b/app/strategies/[address]/edit/_components/info-bar.tsx new file mode 100644 index 00000000..5a5dc917 --- /dev/null +++ b/app/strategies/[address]/edit/_components/info-bar.tsx @@ -0,0 +1,24 @@ +"use client" + +import { TokenPair } from "@/components/token-pair" +import { Badge } from "../../../(list)/_components/badge" +import { useTokensFromQueryParams } from "../_hooks/use-tokens-from-query-params" + +export function InfoBar() { + const { baseToken, quoteToken } = useTokensFromQueryParams() + + return ( +
    +
    + + + Step 2/2 + +
    +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/price-range/components/liquidity-source.tsx b/app/strategies/[address]/edit/_components/price-range/components/liquidity-source.tsx new file mode 100644 index 00000000..3c546c24 --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/components/liquidity-source.tsx @@ -0,0 +1,32 @@ +import Jazzicon, { jsNumberForAddress } from "react-jazzicon" +import { useAccount } from "wagmi" + +import { Caption } from "@/components/typography/caption" +import { Title } from "@/components/typography/title" +import { Skeleton } from "@/components/ui/skeleton" + +export function LiquiditySource({ value = "wallet" }: { value?: "wallet" }) { + const { address } = useAccount() + return ( +
    + + {address ? ( + + ) : ( + + )} + + + + Liquidity source + + + {value} + + +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/price-range/components/price-chart/background-rectangles.tsx b/app/strategies/[address]/edit/_components/price-range/components/price-chart/background-rectangles.tsx new file mode 100644 index 00000000..a12b6bbc --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/components/price-chart/background-rectangles.tsx @@ -0,0 +1,107 @@ +"use client" +import { LinearGradient } from "@visx/gradient" +import type { ScaleLinear } from "d3-scale" +import React from "react" + +type Props = { + height: number + paddingBottom: number + xScale: ScaleLinear + priceRange?: [number, number] | null + midPrice?: number | null +} + +function isPositiveNumber(value: number | null | undefined): value is number { + return value !== null && value !== undefined && value > 0 +} + +export function BackgroundRectangles({ + height, + paddingBottom, + xScale: xScaleTransformed, + priceRange, + midPrice, +}: Props) { + const bidsGradientId = React.useId() + const asksGradientId = React.useId() + const neutralGradientId = React.useId() + + const minPrice = priceRange ? priceRange[0] : null + const maxPrice = priceRange ? priceRange[1] : null + + const leftBidBound = + minPrice && midPrice && minPrice < midPrice ? minPrice : midPrice + const rightBidBound = + maxPrice && midPrice && maxPrice > midPrice ? midPrice : maxPrice + + const leftAskBound = + minPrice && midPrice && minPrice < midPrice ? midPrice : minPrice + const rightAskBound = + maxPrice && midPrice && maxPrice > midPrice ? maxPrice : midPrice + + const rectHeight = height - paddingBottom + + const leftX = leftBidBound && xScaleTransformed(leftBidBound) + const rightX = rightBidBound && xScaleTransformed(rightBidBound) + + if (!(leftX && rightX && isPositiveNumber(rectHeight))) return null + + return ( + <> + {priceRange && midPrice ? ( + <> + + + {leftAskBound && rightAskBound && ( + <> + + + + )} + + ) : ( + minPrice && + maxPrice && ( + <> + + + + ) + )} + + ) +} diff --git a/app/strategies/[address]/edit/_components/price-range/components/price-chart/cursor.tsx b/app/strategies/[address]/edit/_components/price-range/components/price-chart/cursor.tsx new file mode 100644 index 00000000..c5283768 --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/components/price-chart/cursor.tsx @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { ScaleLinear } from "d3-scale" +import React from "react" + +import { cn } from "@/utils" + +interface CursorProps { + xPosition: number + height: number + color?: "red" | "neutral" | "green" + type: "left" | "right" + onMove: (newXPosition: number) => void + xScale: ScaleLinear + svgRef: React.RefObject + viewOnly?: boolean + hidden?: boolean +} + +export default function Cursor({ + xPosition, + height, + color, + type, + onMove, + xScale, + svgRef, + viewOnly = false, + hidden = false, +}: CursorProps) { + const [isDragging, setIsDragging] = React.useState(false) + + const handleMouseDown = React.useCallback((event: MouseEvent) => { + if (viewOnly) return + event.preventDefault() + event.stopPropagation() + setIsDragging(true) + }, []) + + const handleMouseUp = React.useCallback((event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + setIsDragging(false) + }, []) + + const handleMouseMove = React.useCallback( + (event: any) => { + event.preventDefault() + event.stopPropagation() + if (isDragging) { + const svg = svgRef.current + if (!svg) return + const rect = svg.getBoundingClientRect() + const x = event.clientX - rect.left + const newPrice = xScale.invert(x) + onMove(newPrice) + } + }, + [isDragging, onMove, xScale, svgRef], + ) + + React.useEffect(() => { + if (viewOnly) return + const svg = svgRef.current + svg?.addEventListener("mousemove", handleMouseMove) + svg?.addEventListener("mouseup", handleMouseUp) + + return () => { + svg?.removeEventListener("mousemove", handleMouseMove) + svg?.removeEventListener("mouseup", handleMouseUp) + } + }, [ + handleMouseDown, + handleMouseMove, + handleMouseUp, + isDragging, + svgRef, + viewOnly, + ]) + + return ( + <> + + + ) +} diff --git a/app/strategies/[address]/edit/_components/price-range/components/price-chart/custom-brush.tsx b/app/strategies/[address]/edit/_components/price-range/components/price-chart/custom-brush.tsx new file mode 100644 index 00000000..b0654fe7 --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/components/price-chart/custom-brush.tsx @@ -0,0 +1,227 @@ +"use client" + +import { cn } from "@/utils" +import { type ScaleLinear } from "d3-scale" +import React from "react" + +import Cursor from "./cursor" + +type SelectionStatus = "idle" | "start" | "end" + +interface CustomBrushProps { + xScale: ScaleLinear + width: number + height: number + onBrushEnd: (range: [number, number]) => void + value?: [number, number] + onBrushChange: (newRange: [number, number]) => void + svgRef: React.RefObject + viewOnly?: boolean + midPrice?: number | null +} + +function CustomBrush({ + xScale, + width, + height, + onBrushEnd, + value, + onBrushChange, + svgRef, + viewOnly = false, + midPrice, +}: CustomBrushProps) { + const startValueRef = React.useRef(null) + const [selection, setSelection] = React.useState<[number, number] | null>( + value ?? null, + ) + const [selectionStatus, setSelectionStatus] = + React.useState("idle") + const [dragging, setDragging] = React.useState(false) + const [dragMode, setDragMode] = React.useState(false) + const [cursorMoving, setCursorMoving] = React.useState(false) + + const [min, max] = selection ?? [0, 0] + const leftCursorColor = !midPrice + ? "neutral" + : midPrice > min + ? "green" + : "red" + const rightCursorColor = !midPrice + ? "neutral" + : midPrice < max + ? "red" + : "green" + + // Update selection when value prop changes + React.useEffect(() => { + if (JSON.stringify(value) !== JSON.stringify(selection)) { + setSelection(value ?? null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, xScale]) + + const handleMouseDown = React.useCallback( + (event: MouseEvent) => { + if (!event.buttons) return + + const svg = svgRef.current + if (svg) { + const svgRect = svg.getBoundingClientRect() + let xPixel = event.clientX - svgRect.left + xPixel = Math.max(0, Math.min(width, xPixel)) + const x = xScale.invert(xPixel) + if (xPixel >= 0 && xPixel <= width) { + if (selection && x >= selection[0] && x <= selection[1]) { + setDragging(true) + setDragMode(true) + startValueRef.current = x - selection[0] + } else if (!selection) { + startValueRef.current = x + setSelectionStatus("start") + setSelection([x, x]) + setDragMode(false) + } + } + } + }, + [selection, svgRef, width, xScale], + ) + + const handleMouseMove = React.useCallback( + (event: MouseEvent) => { + if (cursorMoving) return + const svg = svgRef.current + if (svg) { + const svgRect = svg.getBoundingClientRect() + const xPixel = event.clientX - svgRect.left + const x = xScale.invert(xPixel) + if ( + dragging && + dragMode && + selection && + startValueRef.current !== null + ) { + const dx = x - startValueRef.current + const newSelection: React.SetStateAction<[number, number] | null> = [ + dx, + dx + (selection[1] - selection[0]), + ] + setSelection(newSelection) // apply the offset + onBrushChange(newSelection) + } else if ( + !dragMode && + startValueRef.current !== null && + event.buttons !== 0 && + selectionStatus !== "end" + ) { + setSelection([startValueRef.current, x]) + } + } + }, + [ + cursorMoving, + svgRef, + xScale, + dragging, + dragMode, + selection, + selectionStatus, + onBrushChange, + ], + ) + + const handleMouseUp = React.useCallback(() => { + setCursorMoving(false) + setDragging(false) + setSelectionStatus("end") + if (selection !== null && onBrushEnd) { + onBrushEnd(selection.sort((a, b) => a - b) as [number, number]) + } + }, [onBrushEnd, selection]) + + const handleCursorMove = (type: "left" | "right", newPrice: number) => { + setCursorMoving(true) + if (!selection) return + if (type === "left") { + const newSelection: [number, number] = [newPrice, selection[1]] + setSelection(newSelection) + onBrushChange(newSelection) + } else { + const newSelection: [number, number] = [selection[0], newPrice] + setSelection(newSelection) + onBrushChange(newSelection) + } + } + + React.useEffect(() => { + if (viewOnly) return + const svg = svgRef.current + svg?.addEventListener("mousedown", handleMouseDown) + svg?.addEventListener("mousemove", handleMouseMove) + svg?.addEventListener("mouseup", handleMouseUp) + + return () => { + svg?.removeEventListener("mousedown", handleMouseDown) + svg?.removeEventListener("mousemove", handleMouseMove) + svg?.removeEventListener("mouseup", handleMouseUp) + } + }, [handleMouseDown, handleMouseMove, handleMouseUp, svgRef, viewOnly]) + + const brushWidth = selection + ? Math.abs(xScale(selection[1]) - xScale(selection[0])) + : 0 + const brushX = selection + ? Math.min(xScale(selection[0]), xScale(selection[1])) + : 0 + + const leftCursorPos = selection ? xScale(selection[0]) : 0 + const rightCursorPos = selection ? xScale(selection[1]) : 0 + + return ( + <> + {selection && ( + + )} + {selection && ( + <> + handleCursorMove("left", newXPosition)} + xScale={xScale} + svgRef={svgRef} + viewOnly={viewOnly} + hidden={selectionStatus === "start"} + /> + handleCursorMove("right", newXPosition)} + xScale={xScale} + svgRef={svgRef} + viewOnly={viewOnly} + hidden={selectionStatus === "start"} + /> + + )} + + ) +} + +export default CustomBrush diff --git a/app/strategies/[address]/edit/_components/price-range/components/price-chart/geometric-distribution-dots.tsx b/app/strategies/[address]/edit/_components/price-range/components/price-chart/geometric-distribution-dots.tsx new file mode 100644 index 00000000..e8141382 --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/components/price-chart/geometric-distribution-dots.tsx @@ -0,0 +1,73 @@ +import type { ScaleLinear } from "d3-scale" + +import { cn } from "@/utils" +import { type PriceRangeChartProps } from "./price-range-chart" + +export type GeometricOffer = { + type: string + price: Big.Big + index: number + gives: Big.Big + tick: number +} + +type Props = { + height: number + paddingBottom: number + xScale: ScaleLinear + onHover?: (offer: GeometricOffer) => void + onHoverOut?: () => void +} & Pick + +export function GeometricKandelDistributionDots({ + geometricKandelDistribution, + xScale, + height, + paddingBottom, + onHover, + onHoverOut, +}: Props) { + if (!geometricKandelDistribution) return null + const dots = [ + ...geometricKandelDistribution?.bids.map((bid) => ({ + ...bid, + type: "bid", + })), + ...geometricKandelDistribution?.asks.map((ask) => ({ + ...ask, + type: "ask", + })), + ] + + return dots.map((geometricOffer) => ( + onHover?.(geometricOffer)} + onMouseOut={onHoverOut} + key={`${geometricOffer.type}-${geometricOffer.index}`} + > + + + + + )) +} diff --git a/app/strategies/[address]/edit/_components/price-range/components/price-chart/geometric-offer-tooltip.tsx b/app/strategies/[address]/edit/_components/price-range/components/price-chart/geometric-offer-tooltip.tsx new file mode 100644 index 00000000..a16e3163 --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/components/price-chart/geometric-offer-tooltip.tsx @@ -0,0 +1,52 @@ +import { Token } from "@mangrovedao/mangrove.js" +import { TooltipWithBounds } from "@visx/tooltip" +import { ScaleLinear } from "d3-scale" + +import { cn } from "@/utils" +import { GeometricOffer } from "./geometric-distribution-dots" + +type Props = { + height: number + paddingBottom: number + xScale: ScaleLinear + onHover?: (offer: GeometricOffer) => void + onHoverOut?: () => void + hoveredGeometricOffer: GeometricOffer + baseToken: Token + quoteToken: Token +} + +export function GeometricOfferTooltip({ + height, + paddingBottom, + xScale: xScaleTransformed, + hoveredGeometricOffer, + baseToken, + quoteToken, +}: Props) { + return ( + +
    +
    + Price:{" "} + {hoveredGeometricOffer.price.toFixed(quoteToken?.displayedDecimals)}{" "} + {quoteToken?.symbol} +
    +
    + Volume:{" "} + {hoveredGeometricOffer.gives.toFixed(baseToken?.displayedDecimals)}{" "} + {baseToken?.symbol} +
    +
    +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/price-range/components/price-chart/merged-offer-tooltip.tsx b/app/strategies/[address]/edit/_components/price-range/components/price-chart/merged-offer-tooltip.tsx new file mode 100644 index 00000000..6f6509f9 --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/components/price-chart/merged-offer-tooltip.tsx @@ -0,0 +1,80 @@ +import { Token } from "@mangrovedao/mangrove.js" +import { TooltipWithBounds } from "@visx/tooltip" +import { ScaleLinear } from "d3-scale" + +import { MergedOffer } from "@/app/strategies/[address]/_utils/inventory" +import { Title } from "@/components/typography/title" +import { cn } from "@/utils" +import { GeometricOffer } from "./geometric-distribution-dots" + +type Props = { + height: number + paddingBottom: number + xScale: ScaleLinear + onHover?: (offer: GeometricOffer) => void + onHoverOut?: () => void + mergedOffer: MergedOffer + baseToken: Token + quoteToken: Token +} + +export function StatusBadge({ isLive }: { isLive: boolean }) { + return ( +
    + + + {isLive ? "Live" : "Empty"} + +
    + ) +} + +export function MergedOfferTooltip({ + height, + paddingBottom, + xScale: xScaleTransformed, + mergedOffer, + baseToken, + quoteToken, +}: Props) { + const isLive = mergedOffer.live + return ( + +
    + +
    + Price:{" "} + {Number(mergedOffer.price).toFixed(quoteToken?.displayedDecimals)}{" "} + {quoteToken?.symbol} +
    +
    + Volume:{" "} + {Number(mergedOffer.gives).toFixed(baseToken?.displayedDecimals)}{" "} + {baseToken?.symbol} +
    +
    +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/price-range/components/price-chart/merged-offers-dots.tsx b/app/strategies/[address]/edit/_components/price-range/components/price-chart/merged-offers-dots.tsx new file mode 100644 index 00000000..37e302c3 --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/components/price-chart/merged-offers-dots.tsx @@ -0,0 +1,63 @@ +import type { ScaleLinear } from "d3-scale" + +import { MergedOffer } from "@/app/strategies/[address]/_utils/inventory" +import { cn } from "@/utils" +import { type PriceRangeChartProps } from "./price-range-chart" + +type Props = { + height: number + paddingBottom: number + xScale: ScaleLinear + onHover?: (offer: MergedOffer) => void + onHoverOut?: () => void + hoveredOffer?: MergedOffer +} & Pick + +export function MergedOffersDots({ + mergedOffers, + xScale, + height, + paddingBottom, + onHover, + onHoverOut, + hoveredOffer, +}: Props) { + if (!mergedOffers) return null + + return mergedOffers.map((mergedOffer) => ( + onHover?.(mergedOffer)} + onMouseOut={onHoverOut} + key={`${mergedOffer.offerType}-${mergedOffer.index}-${mergedOffer.offerId}`} + > + + + + + )) +} diff --git a/app/strategies/[address]/edit/_components/price-range/components/price-chart/mid-price-line.tsx b/app/strategies/[address]/edit/_components/price-range/components/price-chart/mid-price-line.tsx new file mode 100644 index 00000000..e612bd49 --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/components/price-chart/mid-price-line.tsx @@ -0,0 +1,23 @@ +import { type ScaleLinear } from "d3-scale" + +type Props = { + xScale: ScaleLinear + midPrice: number | undefined + height: number +} + +export function MidPriceLine({ xScale, midPrice, height }: Props) { + if (!midPrice) return null + const xPosition = xScale(midPrice) + return ( + + ) +} diff --git a/app/strategies/[address]/edit/_components/price-range/components/price-chart/price-range-chart.tsx b/app/strategies/[address]/edit/_components/price-range/components/price-chart/price-range-chart.tsx new file mode 100644 index 00000000..9c4647e1 --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/components/price-chart/price-range-chart.tsx @@ -0,0 +1,420 @@ +"use client" +import { + Token, + type GeometricKandelDistribution, + type Market, +} from "@mangrovedao/mangrove.js" +import { AxisLeft, AxisTop } from "@visx/axis" +import { curveStep } from "@visx/curve" +import { localPoint } from "@visx/event" +import { scaleLinear } from "@visx/scale" +import { AreaClosed } from "@visx/shape" +import { Zoom } from "@visx/zoom" +import { type ProvidedZoom } from "@visx/zoom/lib/types" +import Big from "big.js" +import { Minus, Plus } from "lucide-react" +import React from "react" +import useResizeObserver from "use-resize-observer" +import { useAccount } from "wagmi" + +import { MergedOffers } from "@/app/strategies/[address]/_utils/inventory" +import { Title } from "@/components/typography/title" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { useKeyPress } from "@/hooks/use-key-press" +import { useHoveredOfferStore } from "@/stores/hovered-offer.store" +import { cn } from "@/utils" +import { BackgroundRectangles } from "./background-rectangles" +import CustomBrush from "./custom-brush" +import { + GeometricKandelDistributionDots, + GeometricOffer, +} from "./geometric-distribution-dots" +import { GeometricOfferTooltip } from "./geometric-offer-tooltip" +import { MergedOfferTooltip } from "./merged-offer-tooltip" +import { MergedOffersDots } from "./merged-offers-dots" +import { MidPriceLine } from "./mid-price-line" +import { RangeTooltips } from "./range-tooltips" +import { SetRangeAnimation } from "./set-range-animation" + +const paddingRight = 54 +const paddingBottom = 44 +const maxToTheTopRatio = 0.8 + +const initialTransform = { + scaleX: 1, + scaleY: 1, + translateX: 0, + translateY: 0, + skewX: 0, + skewY: 0, +} + +export type PriceRangeChartProps = { + baseToken?: Token | null + quoteToken?: Token | null + initialMidPrice?: number + bids?: Market.Offer[] + asks?: Market.Offer[] + onPriceRangeChange?: (priceRange: number[]) => void + priceRange?: [number, number] + viewOnly?: boolean + isLoading?: boolean + geometricKandelDistribution?: ReturnType< + typeof GeometricKandelDistribution.prototype.getOffersWithPrices + > + mergedOffers?: MergedOffers +} + +export function PriceRangeChart({ + bids = [], + asks = [], + initialMidPrice, + onPriceRangeChange, + priceRange, + viewOnly = false, + isLoading = false, + geometricKandelDistribution, + mergedOffers, + baseToken, + quoteToken, +}: PriceRangeChartProps) { + const [hoveredGeometricOffer, setHoveredGeometricOffer] = + React.useState() + const { hoveredOffer, setHoveredOffer } = useHoveredOfferStore() + const { isConnected } = useAccount() + const { ref, width = 0, height = 0 } = useResizeObserver() + const [isMovingRange, setIsMovingRange] = React.useState(false) + const offers = [ + ...bids.map((bid) => ({ ...bid, type: "bid" })), + ...asks.map((ask) => ({ ...ask, type: "ask" })), + ].sort((a, b) => a.price.toNumber() - b.price.toNumber()) + + const lowestAsk = asks?.[0] + const highestBid = bids?.[0] + const midPrice = React.useMemo(() => { + if (!bids?.length || !asks?.length) return initialMidPrice + return Big(lowestAsk?.price ?? 0) + .add(highestBid?.price ?? 0) + .div(2) + .toNumber() + }, [ + asks?.length, + bids?.length, + highestBid?.price, + initialMidPrice, + lowestAsk?.price, + ]) + const xLowerBound = midPrice ? midPrice * 0.7 : 0 // 30% lower than mid price + const xUpperBound = midPrice ? midPrice * 1.3 : 6000 // 30% higher than mid price + + const maxVolume = Math.max(...offers.map((offer) => offer.volume.toNumber())) + + const [xDomain, setXDomain] = React.useState([xLowerBound, xUpperBound]) + const [dragStartPoint, setDragStartPoint] = React.useState<{ + x: number + y: number + } | null>(null) + const [prevPoint, setPrevPoint] = React.useState<{ + x: number + y: number + } | null>(null) + + const altPressed = useKeyPress("Alt") + + // if viewOnly, set the xDomain to the priceRange + React.useEffect(() => { + if (!viewOnly || !priceRange) return + const [min, max] = priceRange + const xLowerBound = min * 0.8 + const xUpperBound = max * 1.1 + setXDomain([xLowerBound, xUpperBound]) + }, [viewOnly, priceRange]) + + React.useEffect(() => { + if (!midPrice || viewOnly) return + const xLowerBound = midPrice * 0.7 // 30% lower than mid price + const xUpperBound = midPrice * 1.3 // 30% higher than mid price + setXDomain([xLowerBound, xUpperBound]) + }, [midPrice]) + + const xScale = scaleLinear({ + domain: xDomain, + range: [0, width - paddingRight], + }) + + const yScale = scaleLinear({ + domain: [0, maxVolume / maxToTheTopRatio], + range: [height - paddingBottom, 0], // subtract paddingBottom from height + }) + + // used for zoom + const rescaleXAxis = (zoom: ProvidedZoom) => { + const newXDomain = xScale.range().map((r) => { + return xScale.invert( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + (r - zoom.transformMatrix.translateX) / zoom.transformMatrix.scaleX, + ) + }) + return xScale.copy().domain(newXDomain) + } + + /** + * Price range selection + */ + const [selectedPriceRange, setSelectedPriceRange] = React.useState< + [number, number] | null + >(priceRange ?? null) + + React.useEffect(() => { + if (!priceRange) return + setSelectedPriceRange(priceRange) + }, [priceRange]) + + const svgRef = React.useRef(null) + return ( + + {(zoom) => { + const xScaleTransformed = rescaleXAxis(zoom) + return ( + <> +
    + Price range + + + + +
    +
    + {altPressed && ( +
    { + // Get the minimum value of the domain + const [minDomain] = xScaleTransformed.domain() + + // If there's no minimum domain value or no drag start point, exit the function + if (!minDomain || !dragStartPoint) return + + // Get the current mouse position + const currentPoint = localPoint(e) ?? { x: 0, y: 0 } + + // Determine the direction of the drag + let dragDirection = "none" + if (prevPoint) { + dragDirection = + currentPoint.x < prevPoint.x ? "right" : "left" + } + + // Update the previous mouse position + setPrevPoint(currentPoint) + + // If the minimum domain value is greater than 0, allow dragging in both directions + if (minDomain > 0) { + zoom.dragMove(e) + } + // If the minimum domain value is less than or equal to 0, only allow dragging to the right + else if (minDomain <= 0 && dragDirection === "right") { + zoom.dragMove(e) + } + }} + onMouseDown={(e) => { + const point = localPoint(e) ?? { x: 0, y: 0 } + setDragStartPoint(point) + zoom.dragStart(e) + }} + onMouseUp={zoom.dragEnd} + onMouseOut={zoom.dragEnd} + /> + )} + {(isLoading || !isConnected) && ( + + )} + {!priceRange && !viewOnly ? ( +
    +
    + +
    +
    + ) : undefined} + + + xScaleTransformed(d.price.toNumber())} + y={(d) => yScale(d.volume.toNumber())} + yScale={yScale} + strokeWidth={1} + curve={curveStep} + className="fill-primary-night-woods" + opacity={priceRange ? 0.8 : 1} + /> + + { + return ( + + + + {formattedValue} + + + ) + }} + /> + + {viewOnly && + !priceRange?.[0] && + !priceRange?.[1] ? undefined : ( + { + setIsMovingRange(false) + setSelectedPriceRange(selectedRange) + if (onPriceRangeChange && selectedRange) { + onPriceRangeChange(selectedRange) + } + }} + onBrushChange={(selectedRange) => { + setIsMovingRange(true) + setSelectedPriceRange(selectedRange) + if (onPriceRangeChange && selectedRange) { + onPriceRangeChange(selectedRange) + } + }} + value={selectedPriceRange ?? undefined} + svgRef={svgRef} + viewOnly={viewOnly} + midPrice={midPrice} + /> + )} + {!isMovingRange && priceRange?.[0] && priceRange?.[1] && ( + setHoveredGeometricOffer(undefined)} + /> + )} + {mergedOffers && ( + { + setHoveredOffer(offer) + }} + onHoverOut={() => setHoveredOffer(undefined)} + hoveredOffer={hoveredOffer} + /> + )} + + + + {hoveredGeometricOffer && baseToken && quoteToken && ( + + )} + {hoveredOffer && baseToken && quoteToken && ( + + )} +
    + + ) + }} + + ) +} diff --git a/app/strategies/[address]/edit/_components/price-range/components/price-chart/range-tooltips.tsx b/app/strategies/[address]/edit/_components/price-range/components/price-chart/range-tooltips.tsx new file mode 100644 index 00000000..e6375fe2 --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/components/price-chart/range-tooltips.tsx @@ -0,0 +1,114 @@ +import { cn } from "@/utils" +import { calculatePriceDifferencePercentage } from "@/utils/numbers" +import { Tooltip } from "@visx/tooltip" +import type { ScaleLinear } from "d3-scale" + +type Props = { + height: number + paddingBottom: number + xScale: ScaleLinear + selectedPriceRange?: [number, number] | null + midPrice?: number | null +} + +export function RangeTooltips({ + height, + paddingBottom, + xScale: xScaleTransformed, + selectedPriceRange, + midPrice, +}: Props) { + const [min, max] = selectedPriceRange ?? [0, 0] + + const minColor = !midPrice ? "neutral" : midPrice > min ? "green" : "red" + const maxColor = !midPrice ? "neutral" : midPrice < max ? "red" : "green" + + return ( + <> + {min && max ? ( + <> + + + + ) : undefined} + {midPrice ? ( + +
    + Mid {midPrice.toFixed(2)} +
    +
    + ) : undefined} + + ) +} + +type RangeTooltipProps = { + height: number + paddingBottom: number + xScale: ScaleLinear + value: number + color: "green" | "red" | "neutral" + text: string + midPrice?: number | null +} + +function RangeTooltip({ + height, + paddingBottom, + xScale: xScaleTransformed, + value, + color, + text, + midPrice, +}: RangeTooltipProps) { + const percentage = calculatePriceDifferencePercentage({ + price: midPrice, + value, + }) + + return ( + +
    + {text} {value.toFixed(2)}{" "} + {midPrice ? `${percentage.toFixed(2)}% filled` : ""} +
    +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/price-range/components/price-chart/set-range-animation.tsx b/app/strategies/[address]/edit/_components/price-range/components/price-chart/set-range-animation.tsx new file mode 100644 index 00000000..2d7940ec --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/components/price-chart/set-range-animation.tsx @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useGSAP } from "@gsap/react" +import { gsap } from "gsap" +import { MousePointer, MousePointerClick } from "lucide-react" +import React from "react" + +export function SetRangeAnimation() { + const pointerRef = React.useRef(null) + const clickRef = React.useRef(null) + const containerRef = React.useRef(null) + + useGSAP( + () => { + if (!containerRef.current) return + gsap + .timeline({ + repeat: -1, + }) + .from(clickRef.current, { + display: "none", + }) + .from( + pointerRef.current, + { + opacity: 0, + }, + "<", + ) + .to( + pointerRef.current, + { + opacity: 1, + }, + "<", + ) + .to( + pointerRef.current, + { + display: "none", + }, + "<", + ) + .to(clickRef.current, { + display: "block", + opacity: 1, + }) + .to( + clickRef.current, + { + x: containerRef.current?.offsetWidth / 2, + duration: 2.5, + delay: 0.3, + ease: "Expo.out", + }, + "<", + ) + .to(clickRef.current, { + delay: 0.3, + opacity: 0, + }) + .play() + }, + { + scope: containerRef, + }, + ) + + return ( +
    + + +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/price-range/components/risk-appetite.tsx b/app/strategies/[address]/edit/_components/price-range/components/risk-appetite.tsx new file mode 100644 index 00000000..50cc5257 --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/components/risk-appetite.tsx @@ -0,0 +1,49 @@ +import { Caption } from "@/components/typography/caption" +import { Title } from "@/components/typography/title" +import { Skeleton } from "@/components/ui/skeleton" +import { cn } from "@/utils" +import { RiskAppetite } from "@/utils/cashness" + +export function RiskAppetiteBadge({ value }: { value?: RiskAppetite }) { + return ( +
    + + + + + + + + Risk appetite + + {!value ? ( + + ) : ( + + {value} + + )} + +
    + ) +} diff --git a/app/strategies/[address]/edit/_components/price-range/price-range.tsx b/app/strategies/[address]/edit/_components/price-range/price-range.tsx new file mode 100644 index 00000000..2af125a4 --- /dev/null +++ b/app/strategies/[address]/edit/_components/price-range/price-range.tsx @@ -0,0 +1,353 @@ +"use client" +import Link from "next/link" +import { debounce } from "radash" +import React from "react" + +import { EnhancedNumericInput } from "@/components/token-input" +import { Button } from "@/components/ui/button" +import withClientOnly from "@/hocs/withClientOnly" +import { useTokenFromAddress } from "@/hooks/use-token-from-address" +import useMarket from "@/providers/market" +import { + calculatePriceDifferencePercentage, + calculatePriceFromPercentage, +} from "@/utils/numbers" +import { Address } from "viem" +import { + ChangingFrom, + useNewStratStore, +} from "../../../../new/_stores/new-strat.store" +import useKandel from "../../../_providers/kandel-strategy" +import EditStrategyDialog from "../edit-strategy-dialog" +import { LiquiditySource } from "./components/liquidity-source" +import { PriceRangeChart } from "./components/price-chart/price-range-chart" +import { RiskAppetiteBadge } from "./components/risk-appetite" + +export const PriceRange = withClientOnly(function ({ + className, +}: { + className?: string +}) { + const { requestBookQuery, midPrice, market, riskAppetite } = useMarket() + const { mergedOffers, strategyQuery } = useKandel() + + const { data: baseToken } = useTokenFromAddress( + market?.base.address as Address, + ) + const { data: quoteToken } = useTokenFromAddress( + market?.quote.address as Address, + ) + + const priceDecimals = market?.quote.decimals + + const [summaryDialog, setSummaryDialog] = React.useState(false) + const [minPrice, setMinPrice] = React.useState("") + const [minPercentage, setMinPercentage] = React.useState("") + const [maxPrice, setMaxPrice] = React.useState("") + const [maxPercentage, setMaxPercentage] = React.useState("") + + const { + baseDeposit, + quoteDeposit, + bountyDeposit, + stepSize, + numberOfOffers, + distribution, + offersWithPrices, + globalError, + errors, + isChangingFrom, + sendFrom, + receiveTo, + setPriceRange, + setOffersWithPrices, + setErrors, + setIsChangingFrom, + } = useNewStratStore() + + const formIsInvalid = + Object.keys(errors).length > 0 || + !!globalError || + !minPrice || + !maxPrice || + !stepSize || + !numberOfOffers || + !distribution + + const priceRange: [number, number] | undefined = + minPrice && maxPrice ? [Number(minPrice), Number(maxPrice)] : undefined + const hasLiveOffers = mergedOffers?.some((x) => x.live) + + React.useEffect(() => { + if (strategyQuery.data?.offers.some((x) => x.live)) { + setMinPrice(strategyQuery.data?.min || "0") + setMaxPrice(strategyQuery.data?.max || "0") + } + }, [strategyQuery.data?.max, strategyQuery.data?.min]) + + React.useEffect(() => { + if (isChangingFrom !== "minPercentage" && minPrice && midPrice) { + const minPriceNumber = Number(minPrice) + const midPriceNumber = Number(midPrice) + const percentageDifference = calculatePriceDifferencePercentage({ + price: midPriceNumber, + value: minPriceNumber, + }) + setMinPercentage(percentageDifference.toFixed(2)) // Keep 2 decimal places + } + }, [minPrice, midPrice, isChangingFrom]) + + React.useEffect(() => { + if (isChangingFrom !== "maxPercentage" && maxPrice && midPrice) { + const maxPriceNumber = Number(maxPrice) + const midPriceNumber = Number(midPrice) + const percentageDifference = calculatePriceDifferencePercentage({ + price: midPriceNumber, + value: maxPriceNumber, + }) + setMaxPercentage(percentageDifference.toFixed(2)) // Keep 2 decimal places + } + }, [isChangingFrom, maxPrice, midPrice]) + + const handleFieldChange = (field: ChangingFrom) => { + setIsChangingFrom(field) + } + + const handleOnPriceRangeChange = ([min, max]: number[]) => { + if (!min || !max) return + handleFieldChange("chart") + setMinPrice(min.toFixed(priceDecimals)) + setMaxPrice(max.toFixed(priceDecimals)) + } + + const handleMinPriceChange = (e: React.ChangeEvent) => { + handleFieldChange("minPrice") + const price = e.target.value + setMinPrice(price) + } + + const handleMaxPriceChange = (e: React.ChangeEvent) => { + handleFieldChange("maxPrice") + const price = e.target.value + setMaxPrice(price) + } + + const handleMinPercentageChange = ( + e: React.ChangeEvent, + ) => { + const percentage = e.target.value + if (percentage === "-" || !isFinite(Number(percentage))) { + return + } + + handleFieldChange("minPercentage") + + if (midPrice) { + const percentage = Number(e.target.value) + const newMinPrice = calculatePriceFromPercentage({ + percentage, + basePrice: midPrice, + }) + setMinPrice(newMinPrice.toFixed(priceDecimals)) + } + } + + const handleMaxPercentageChange = ( + e: React.ChangeEvent, + ) => { + const percentage = e.target.value + if (percentage === "-" || !isFinite(Number(percentage))) { + return + } + + handleFieldChange("maxPercentage") + + if (midPrice) { + const percentage = Number(e.target.value) + const newMaxPrice = calculatePriceFromPercentage({ + percentage, + basePrice: midPrice, + }) + setMaxPrice(newMaxPrice.toFixed(priceDecimals)) + } + } + + React.useEffect(() => { + const newErrors = { ...errors } + + if (Number(minPrice) > Number(maxPrice) && maxPrice) { + newErrors.minPrice = "Min price cannot be greater than max price" + } else { + delete newErrors.minPrice + } + + if (Number(maxPrice) < Number(minPrice) && minPrice) { + newErrors.maxPrice = "Max price cannot be less than min price" + } else { + delete newErrors.maxPrice + } + + if (Number(minPercentage) > Number(maxPercentage) && maxPercentage) { + newErrors.minPercentage = + "Min percentage cannot be greater than max percentage" + } else { + delete newErrors.minPercentage + } + + if (Number(maxPercentage) < Number(minPercentage) && minPercentage) { + newErrors.maxPercentage = + "Max percentage cannot be less than min percentage" + } else { + delete newErrors.maxPercentage + } + + setErrors(newErrors) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [minPrice, maxPrice, minPercentage, maxPercentage]) + + const debouncedSetPriceRange = React.useCallback( + debounce( + { + delay: 300, + }, + (min: string, max: string) => setPriceRange(min, max), + ), + [], + ) + + React.useEffect(() => { + if (offersWithPrices) setOffersWithPrices(undefined) + if (!minPrice || !maxPrice) return + debouncedSetPriceRange(minPrice, maxPrice) + }, [minPrice, maxPrice]) + + return ( +
    +
    +
    + {/* */} + + +
    +
    + + {/* CHART */} +
    + + +
    + {market?.quote && ( +
    + + + +
    + )} +
    + +
    + {market?.quote && ( +
    + + +
    + )} +
    + + {globalError && ( +

    + {globalError} +

    + )} + +
    + + +
    + setSummaryDialog(false)} + /> +
    +
    + ) +}) diff --git a/app/strategies/[address]/edit/_hooks/use-approve-kandel-strategy.ts b/app/strategies/[address]/edit/_hooks/use-approve-kandel-strategy.ts new file mode 100644 index 00000000..472d264c --- /dev/null +++ b/app/strategies/[address]/edit/_hooks/use-approve-kandel-strategy.ts @@ -0,0 +1,50 @@ +import { useMutation } from "@tanstack/react-query" + +import useKandel from "@/app/strategies/(list)/_providers/kandel-strategies" +import useMarket from "@/providers/market" + +import { getTitleDescriptionErrorMessages } from "@/utils/tx-error-messages" +import { toast } from "sonner" +import { NewStratStore } from "../../../new/_stores/new-strat.store" + +type FormValues = Pick + +export function useApproveKandelStrategy({ + kandelAddress, +}: { + kandelAddress?: string +}) { + const { market } = useMarket() + const { kandelStrategies } = useKandel() + return useMutation({ + mutationFn: async ({ baseDeposit, quoteDeposit }: FormValues) => { + try { + if (!(market && kandelStrategies && kandelAddress)) return + + const kandelInstance = await kandelStrategies.instance({ + address: kandelAddress, + market, + type: "smart", + }) + + const approvalTxs = await kandelInstance.approveIfHigher( + Number(baseDeposit), + Number(quoteDeposit), + ) + + // waiting for all approvals + await Promise.all(approvalTxs.map((tx) => tx?.wait())) + + toast.success("Kandel strategy successfully approved") + } catch (error) { + const { description } = getTitleDescriptionErrorMessages(error as Error) + toast.error(description) + console.error(error) + throw new Error(description) + } + + // TODO: invalidate strategies query + }, + meta: { disableGenericError: true }, + }) +} diff --git a/app/strategies/[address]/edit/_hooks/use-edit-kandel-strategy.ts b/app/strategies/[address]/edit/_hooks/use-edit-kandel-strategy.ts new file mode 100644 index 00000000..3275e831 --- /dev/null +++ b/app/strategies/[address]/edit/_hooks/use-edit-kandel-strategy.ts @@ -0,0 +1,68 @@ +import { GeometricKandelDistribution } from "@mangrovedao/mangrove.js" +import { useMutation } from "@tanstack/react-query" +import { toast } from "sonner" + +import useKandel from "@/app/strategies/(list)/_providers/kandel-strategies" +import useMarket from "@/providers/market" +import { getTitleDescriptionErrorMessages } from "@/utils/tx-error-messages" +import { NewStratStore } from "../../../new/_stores/new-strat.store" + +type FormValues = Pick< + NewStratStore, + | "baseDeposit" + | "quoteDeposit" + | "numberOfOffers" + | "stepSize" + | "bountyDeposit" +> & { + distribution: GeometricKandelDistribution | undefined + kandelAddress?: string +} + +export function useEditKandelStrategy() { + const { market } = useMarket() + const { kandelStrategies } = useKandel() + + return useMutation({ + mutationFn: async ({ + baseDeposit, + quoteDeposit, + distribution, + bountyDeposit, + stepSize, + numberOfOffers, + kandelAddress, + }: FormValues) => { + try { + if (!(market && kandelStrategies && distribution && kandelAddress)) + return + + const kandelInstance = await kandelStrategies.instance({ + address: kandelAddress, + market, + type: "smart", + }) + + const populateTxs = await kandelInstance.populateGeometricDistribution({ + distribution, + // depositBaseAmount: baseDeposit, + // depositQuoteAmount: quoteDeposit, + funds: bountyDeposit, + parameters: { + pricePoints: Number(numberOfOffers) + 1, + stepSize: Number(stepSize), + }, + }) + + await Promise.all(populateTxs.map((x) => x.wait())) + toast.success("Kandel strategy successfully edited") + } catch (error) { + const { description } = getTitleDescriptionErrorMessages(error as Error) + toast.error(description) + console.error(error) + throw new Error(description) + } + }, + meta: { disableGenericError: true }, + }) +} diff --git a/app/strategies/[address]/edit/_hooks/use-kandel-requirements.tsx b/app/strategies/[address]/edit/_hooks/use-kandel-requirements.tsx new file mode 100644 index 00000000..713fe085 --- /dev/null +++ b/app/strategies/[address]/edit/_hooks/use-kandel-requirements.tsx @@ -0,0 +1,139 @@ +"use client" +import { useQuery } from "@tanstack/react-query" +import { BigSource } from "big.js" + +import useKandel from "@/app/strategies/(list)/_providers/kandel-strategies" +import useMarket from "@/providers/market" +import { getErrorMessage } from "@/utils/errors" +import { ChangingFrom } from "../../../new/_stores/new-strat.store" + +export type Params = { + onAave?: boolean + stepSize: number | string + minPrice: BigSource + maxPrice: BigSource + availableBase?: BigSource + availableQuote?: BigSource + numberOfOffers: number | string + isChangingFrom?: ChangingFrom +} + +export function useKandelRequirements({ + onAave = false, + minPrice, + maxPrice, + availableBase, + availableQuote, + stepSize, + numberOfOffers, +}: Params) { + const { market, midPrice } = useMarket() + const { kandelStrategies, generator, config } = useKandel() + return useQuery({ + queryKey: [ + "kandel-requirements", + minPrice, + maxPrice, + midPrice, + stepSize, + numberOfOffers, + onAave, + market?.base.id, + market?.quote?.id, + ], + queryFn: async () => { + if ( + !( + kandelStrategies && + generator && + market && + midPrice && + config && + minPrice && + maxPrice + ) + ) + return null + + try { + const minimumBasePerOffer = + await kandelStrategies.seeder.getMinimumVolume({ + market, + offerType: "asks", + type: "smart", + }) + + const minimumQuotePerOffer = + await kandelStrategies.seeder.getMinimumVolume({ + market, + offerType: "bids", + type: "smart", + }) + + const param: Parameters< + typeof generator.calculateMinimumDistribution + >[number] = { + minimumBasePerOffer, + minimumQuotePerOffer, + distributionParams: { + generateFromMid: false, + minPrice, + maxPrice, + stepSize: Number(stepSize) ?? config.stepSize, + midPrice, + pricePoints: Number(numberOfOffers) + 1, // number of offers = price points - 1 + }, + } + + // Calculate a candidate distribution with the recommended minimum volumes given the price range. + const minimumDistribution = + await generator.calculateMinimumDistribution(param) + + // requiredBase / quote => minimum to use in the fields + const { requiredBase, requiredQuote } = + minimumDistribution.getOfferedVolumeForDistribution() + + const distribution = + await generator.recalculateDistributionFromAvailable({ + distribution: minimumDistribution, + availableBase: availableBase ? availableBase : requiredBase, + availableQuote: availableQuote ? availableQuote : requiredQuote, + }) + + const offers = distribution.getOffersWithPrices() + const offersWithPrices = { + asks: offers.asks.filter((offer) => offer.gives.gt(0)), + bids: offers.bids.filter((offer) => offer.gives.gt(0)), + } + + // minimum allowed value for gas (or bounty) + const requiredBounty = + await kandelStrategies.seeder.getRequiredProvision( + { + type: "smart", + market, + liquiditySharing: false, + }, + distribution, + ) + + return { + requiredBase, + requiredQuote, + requiredBounty, + distribution, + offersWithPrices, + pricePoints: minimumDistribution.pricePoints, + } + } catch (e) { + const message = getErrorMessage(e) + console.error("Error: ", message) + if (message.includes("revert")) { + throw new Error(`Error: one of the parameters is invalid`) + } + throw message + } + }, + enabled: !!(kandelStrategies && generator && market && midPrice), + }) +} diff --git a/app/strategies/[address]/edit/_hooks/use-retract-offers.ts b/app/strategies/[address]/edit/_hooks/use-retract-offers.ts new file mode 100644 index 00000000..e6db8050 --- /dev/null +++ b/app/strategies/[address]/edit/_hooks/use-retract-offers.ts @@ -0,0 +1,63 @@ +import { useMutation } from "@tanstack/react-query" + +import useKandel from "@/app/strategies/(list)/_providers/kandel-strategies" +import useMarket from "@/providers/market" + +import { getTitleDescriptionErrorMessages } from "@/utils/tx-error-messages" +import { toast } from "sonner" + +export function useRetractOffers({ + kandelAddress, +}: { + kandelAddress?: string +}) { + const { market } = useMarket() + const { kandelStrategies } = useKandel() + + return useMutation({ + mutationFn: async () => { + try { + if (!(market && kandelStrategies && kandelAddress)) + throw new Error("Could not retract offers") + + const kandelInstance = await kandelStrategies.instance({ + address: kandelAddress, + market, + type: "smart", + }) + + const txs = await kandelInstance.retractOffers() + await Promise.all(txs.map((x) => x.wait())) + toast.success("Kandel offers successfully retracted") + return txs + } catch (error) { + const { description } = getTitleDescriptionErrorMessages(error as Error) + toast.error(description) + console.error(error) + throw new Error(description) + } + }, + meta: { disableGenericError: true }, + onSuccess: async (data) => { + // const { order, result } = data + // /* + // * We use a custom callback to handle the success message once it's ready. + // * This is because the onSuccess callback from the mutation will only be triggered + // * after all the preceding logic has been executed. + // */ + // onResult?.(result) + // try { + // // Start showing loading state indicator on parts of the UI that depend on + // startLoading([TRADE.TABLES.ORDERS, TRADE.TABLES.FILLS]) + // const { blockNumber } = await (await order.response).wait() + // await resolveWhenBlockIsIndexed.mutateAsync({ + // blockNumber, + // }) + // queryClient.invalidateQueries({ queryKey: ["orders"] }) + // queryClient.invalidateQueries({ queryKey: ["fills"] }) + // } catch (error) { + // console.error(error) + // } + }, + }) +} diff --git a/app/strategies/[address]/edit/_hooks/use-tokens-from-query-params.tsx b/app/strategies/[address]/edit/_hooks/use-tokens-from-query-params.tsx new file mode 100644 index 00000000..49b18bd4 --- /dev/null +++ b/app/strategies/[address]/edit/_hooks/use-tokens-from-query-params.tsx @@ -0,0 +1,16 @@ +import type { Address } from "viem" + +import { useTokenFromId } from "@/hooks/use-token-from-id" +import { useSearchParams } from "next/navigation" + +export function useTokensFromQueryParams() { + const searchParams = useSearchParams() + const market = searchParams.get("market") + const [baseId, quoteId] = market?.split(",") ?? [] + const { data: baseToken } = useTokenFromId(baseId as Address) + const { data: quoteToken } = useTokenFromId(quoteId as Address) + return { + baseToken, + quoteToken, + } +} diff --git a/app/strategies/[address]/edit/layout.tsx b/app/strategies/[address]/edit/layout.tsx new file mode 100644 index 00000000..563421d3 --- /dev/null +++ b/app/strategies/[address]/edit/layout.tsx @@ -0,0 +1,23 @@ +import { Metadata } from "next" +import React from "react" + +import { KandelStrategiesProvider } from "@/app/strategies/(list)/_providers/kandel-strategies" +import { IndexerSdkProvider } from "@/providers/mangrove-indexer" +import { MarketProvider } from "@/providers/market" + +export const metadata: Metadata = { + title: "Edit Strategy | Mangrove DEX", + description: "Edit Strategy on Mangrove DEX", +} + +export default function Layout({ children }: React.PropsWithChildren) { + return ( + + + +
    {children}
    +
    +
    +
    + ) +} diff --git a/app/strategies/[address]/edit/page.tsx b/app/strategies/[address]/edit/page.tsx new file mode 100644 index 00000000..06a4c634 --- /dev/null +++ b/app/strategies/[address]/edit/page.tsx @@ -0,0 +1,39 @@ +"use client" +import { useParams, useRouter, useSearchParams } from "next/navigation" + +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" +import { Address } from "viem" +import { Form } from "./_components/form/form" +import { InfoBar } from "./_components/info-bar" +import { PriceRange } from "./_components/price-range/price-range" + +export default function Page() { + const router = useRouter() + const searchParams = useSearchParams() + const market = searchParams.get("market") + const { address } = useParams<{ address: Address }>() + + if (!address) { + router.push("/strategies") + } + + if (!market) { + router.push(`/strategies/${address}`) + } + + return ( +
    + +
    + +
    + + + + + + +
    +
    + ) +} diff --git a/app/strategies/[address]/layout.tsx b/app/strategies/[address]/layout.tsx index f2effc54..3c8da88d 100644 --- a/app/strategies/[address]/layout.tsx +++ b/app/strategies/[address]/layout.tsx @@ -1,3 +1,4 @@ +import { Metadata } from "next" import React from "react" import { KandelStrategyProvider } from "@/app/strategies/[address]/_providers/kandel-strategy" @@ -6,6 +7,11 @@ import { IndexerSdkProvider } from "@/providers/mangrove-indexer" import { MarketProvider } from "@/providers/market" import { KandelStrategiesProvider } from "../(list)/_providers/kandel-strategies" +export const metadata: Metadata = { + title: "Manage Strategy | Mangrove DEX", + description: "Manage Strategy on Mangrove DEX", +} + export default function Layout({ children }: React.PropsWithChildren) { return ( diff --git a/app/strategies/[address]/page.tsx b/app/strategies/[address]/page.tsx index c8ef01c2..023f0d5a 100644 --- a/app/strategies/[address]/page.tsx +++ b/app/strategies/[address]/page.tsx @@ -4,25 +4,37 @@ import React from "react" import { TokenPair } from "@/components/token-pair" import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" import Status from "../(shared)/_components/status" +import useStrategyStatus from "../(shared)/_hooks/use-strategy-status" import BackButton from "./_components/back-button" import BlockExplorer from "./_components/block-explorer" -import CloseDialog from "./_components/parameters/dialogs/close" +import CloseStrategyDialog from "./_components/parameters/dialogs/close" import InformationBanner from "./_components/shared/information-banner" import Tabs from "./_components/tabs" import useKandel from "./_providers/kandel-strategy" export default function Page() { const [closeStrategy, toggleCloseStrategy] = React.useState(false) + const { push } = useRouter() const { + strategyStatusQuery, strategyQuery, strategyAddress, baseToken, quoteToken, blockExplorerUrl, } = useKandel() + const { base, quote, address, offers } = strategyQuery.data ?? {} + const { data } = useStrategyStatus({ + address, + base, + quote, + offers, + }) + const showStatus = base && quote && address && offers return (
    @@ -37,32 +49,41 @@ export default function Page() { quoteToken={quoteToken} tokenClasses="h-7 w-7" /> - {showStatus ? ( - - ) : undefined} + {showStatus ? : undefined} +
    +
    + +
    -
    - toggleCloseStrategy(false)} /> diff --git a/app/strategies/new/_components/form/form.tsx b/app/strategies/new/_components/form/form.tsx index fbd1a5a9..0ef162aa 100644 --- a/app/strategies/new/_components/form/form.tsx +++ b/app/strategies/new/_components/form/form.tsx @@ -1,16 +1,19 @@ "use client" +import { CustomBalance } from "@/components/stateful/token-balance/custom-balance" import { TokenBalance } from "@/components/stateful/token-balance/token-balance" import { EnhancedNumericInput } from "@/components/token-input" import { Skeleton } from "@/components/ui/skeleton" +import { useTokenBalance } from "@/hooks/use-token-balance" import { cn } from "@/utils" import { Fieldset } from "../fieldset" import { MinimumRecommended } from "./components/minimum-recommended" import { MustBeAtLeastInfo } from "./components/must-be-at-least-info" -import useForm, { MIN_PRICE_POINTS, MIN_RATIO, MIN_STEP_SIZE } from "./use-form" +import useForm, { MIN_NUMBER_OF_OFFERS, MIN_STEP_SIZE } from "./use-form" export function Form({ className }: { className?: string }) { const { + address, baseToken, quoteToken, requiredBase, @@ -20,21 +23,44 @@ export function Form({ className }: { className?: string }) { quoteDeposit, fieldsDisabled, errors, - handleBaseDepositChange, - handleQuoteDepositChange, kandelRequirementsQuery, isChangingFrom, - pricePoints, - handlePricePointsChange, - ratio, - handleRatioChange, + numberOfOffers, stepSize, - handleStepSizeChange, nativeBalance, bountyDeposit, + sendFrom, + receiveTo, + mangroveLogics, + handleBaseDepositChange, + handleQuoteDepositChange, + handleNumberOfOffersChange, + handleStepSizeChange, handleBountyDepositChange, + handleSendFromChange, + handleReceiveToChange, } = useForm() + // const { sendFromLogics, receiveToLogics, sendFromBalance, receiveToBalance } = + // useLiquiditySourcing({ + // sendToken: baseToken, + // sendFrom, + // receiveTo, + // receiveToken: quoteToken, + // fundOwner: address, + // mangroveLogics, + // }) + + const { formatted: baseTokenBalance } = useTokenBalance(baseToken) + const { formatted: quoteTokenBalance } = useTokenBalance(quoteToken) + + // const baseBalance = sendFromBalance + // ? sendFromBalance.formatted + // : baseTokenBalance + // const quoteBalance = receiveToBalance + // ? receiveToBalance.formatted + // : quoteTokenBalance + if (!baseToken || !quoteToken) return (
    @@ -49,6 +75,94 @@ export function Form({ className }: { className?: string }) { e.preventDefault() }} > + {/*
    +
    +
    + + + +
    + +
    + + + +
    +
    +
    */} +
    - -
    -
    - - -
    { + kandelRequirementsQuery.refetch() + }, [baseDeposit, quoteDeposit]) + const setOffersWithPrices = useNewStratStore( (store) => store.setOffersWithPrices, ) @@ -87,20 +96,14 @@ export default function useForm() { setGlobalError(undefined) }, [kandelRequirementsQuery.error]) - // Update ratio field if number of price points is changing - React.useEffect(() => { - if (isChangingFrom === "ratio" || !priceRatio) return - setRatio(priceRatio.toFixed(4)) - }, [priceRatio]) - React.useEffect(() => { if ( - isChangingFrom === "pricePoints" || + isChangingFrom === "numberOfOffers" || !points || - Number(pricePoints) === points + Number(numberOfOffers) === points - 1 ) return - setPricePoints(points.toString()) + setNumberOfOffers(points.toString()) }, [points]) React.useEffect(() => { @@ -111,6 +114,22 @@ export default function useForm() { setIsChangingFrom(field) } + const handleSendFromChange = ( + e: React.ChangeEvent | string, + ) => { + handleFieldChange("sendFrom") + const value = typeof e === "string" ? e : e.target.value + setSendFrom(value) + } + + const handleReceiveToChange = ( + e: React.ChangeEvent | string, + ) => { + handleFieldChange("receiveTo") + const value = typeof e === "string" ? e : e.target.value + setReceiveTo(value) + } + const handleBaseDepositChange = ( e: React.ChangeEvent | string, ) => { @@ -127,20 +146,12 @@ export default function useForm() { setQuoteDeposit(value) } - const handlePricePointsChange = ( - e: React.ChangeEvent | string, - ) => { - handleFieldChange("pricePoints") - const value = typeof e === "string" ? e : e.target.value - setPricePoints(value) - } - - const handleRatioChange = ( + const handleNumberOfOffersChange = ( e: React.ChangeEvent | string, ) => { - handleFieldChange("ratio") + handleFieldChange("numberOfOffers") const value = typeof e === "string" ? e : e.target.value - setRatio(value) + setNumberOfOffers(value) } const handleStepSizeChange = ( @@ -168,6 +179,11 @@ export default function useForm() { "Base deposit cannot be greater than wallet balance" } else if (requiredBase?.gt(0) && Number(baseDeposit) === 0) { newErrors.baseDeposit = "Base deposit must be greater than 0" + } else if ( + requiredBase?.gt(0) && + Number(requiredBase) > Number(baseDeposit) + ) { + newErrors.baseDeposit = "Base deposit must be uptated" } else { delete newErrors.baseDeposit } @@ -178,41 +194,45 @@ export default function useForm() { "Quote deposit cannot be greater than wallet balance" } else if (requiredQuote?.gt(0) && Number(quoteDeposit) === 0) { newErrors.quoteDeposit = "Quote deposit must be greater than 0" + } else if ( + requiredQuote?.gt(0) && + Number(requiredQuote) > Number(quoteDeposit) + ) { + newErrors.quoteDeposit = "Quote deposit must updated" } else { delete newErrors.quoteDeposit } - if (Number(pricePoints) < Number(MIN_PRICE_POINTS) && pricePoints) { - newErrors.pricePoints = "Price points must be at least 2" - } else { - delete newErrors.pricePoints - } - - if (Number(ratio) < Number(MIN_RATIO) && ratio) { - newErrors.ratio = "Ratio must be at least 1.001" + if ( + Number(numberOfOffers) < Number(MIN_NUMBER_OF_OFFERS) && + numberOfOffers + ) { + newErrors.numberOfOffers = "Number of offers must be at least 1" } else { - delete newErrors.ratio + delete newErrors.numberOfOffers } if ( (Number(stepSize) < Number(MIN_STEP_SIZE) || - Number(stepSize) >= Number(pricePoints)) && + Number(stepSize) >= Number(numberOfOffers) + 1) && stepSize ) { newErrors.stepSize = - "Step size must be at least 1 and inferior to price points" + "Step size must be at least 1 and inferior or equal to number of offers" } else { delete newErrors.stepSize } - if ( - Number(bountyDeposit) > Number(nativeBalance?.formatted) && - bountyDeposit - ) { + if (Number(bountyDeposit) > Number(nativeBalance?.value) && bountyDeposit) { newErrors.bountyDeposit = "Bounty deposit cannot be greater than wallet balance" } else if (requiredBounty?.gt(0) && Number(bountyDeposit) === 0) { newErrors.bountyDeposit = "Bounty deposit must be greater than 0" + } else if ( + requiredBounty?.gt(0) && + Number(requiredBounty) > Number(bountyDeposit) + ) { + newErrors.bountyDeposit = "Bounty deposit must be greater than 0" } else { delete newErrors.bountyDeposit } @@ -221,8 +241,7 @@ export default function useForm() { }, [ baseDeposit, quoteDeposit, - pricePoints, - ratio, + numberOfOffers, stepSize, bountyDeposit, requiredBase, @@ -230,27 +249,31 @@ export default function useForm() { ]) return { + address, baseToken, quoteToken, requiredBase, requiredQuote, requiredBounty, isChangingFrom, - pricePoints, + numberOfOffers, baseDeposit, quoteDeposit, - handleBaseDepositChange, - handleQuoteDepositChange, - handlePricePointsChange, + nativeBalance, + bountyDeposit, fieldsDisabled, errors, kandelRequirementsQuery, - ratio, - handleRatioChange, stepSize, + sendFrom, + receiveTo, + mangroveLogics, + handleBaseDepositChange, + handleQuoteDepositChange, + handleNumberOfOffersChange, + handleSendFromChange, + handleReceiveToChange, handleStepSizeChange, - nativeBalance, - bountyDeposit, handleBountyDepositChange, } } diff --git a/app/strategies/new/_components/launch-strategy-dialog.tsx b/app/strategies/new/_components/launch-strategy-dialog.tsx index bc846185..d05df1e0 100644 --- a/app/strategies/new/_components/launch-strategy-dialog.tsx +++ b/app/strategies/new/_components/launch-strategy-dialog.tsx @@ -2,24 +2,31 @@ import { Token } from "@mangrovedao/mangrove.js" import React from "react" import { useAccount, useBalance } from "wagmi" +import { ActivateRouter } from "@/app/trade/_components/forms/components/activate-router" +import { ApproveStep } from "@/app/trade/_components/forms/components/approve-step" +import { useSpenderAddress } from "@/app/trade/_components/forms/hooks/use-spender-address" import Dialog from "@/components/dialogs/dialog" import { TokenPair } from "@/components/token-pair" import { Text } from "@/components/typography/text" import { Button, type ButtonProps } from "@/components/ui/button" import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" +import { useInfiniteApproveToken } from "@/hooks/use-infinite-approve-token" +import { useIsTokenInfiniteAllowance } from "@/hooks/use-is-token-infinite-allowance" import { useStep } from "@/hooks/use-step" +import useMangrove from "@/providers/mangrove" import useMarket from "@/providers/market" -import { useApproveKandelStrategy } from "../_hooks/use-approve-kandel-strategy" +import { useActivateStrategySmartRouter } from "../../(shared)/_hooks/use-activate-smart-router" +import { useStrategySmartRouter } from "../../(shared)/_hooks/use-smart-router" +import { useCreateKandelStrategy } from "../_hooks/use-approve-kandel-strategy" import { useLaunchKandelStrategy } from "../_hooks/use-launch-kandel-strategy" import { NewStratStore } from "../_stores/new-strat.store" -import { ApproveStep } from "./form/components/approve-step" import { Steps } from "./form/components/steps" type StrategyDetails = Omit< NewStratStore, "isChangingFrom" | "globalError" | "errors" | "priceRange" -> & { onAave?: boolean; riskAppetite?: string; priceRange?: [number, number] } +> & { riskAppetite?: string; priceRange?: [number, number] } type Props = { strategy?: StrategyDetails @@ -40,6 +47,7 @@ export default function DeployStrategyDialog({ }: Props) { const { address } = useAccount() const { market } = useMarket() + const { mangrove } = useMangrove() const { base: baseToken, quote: quoteToken } = market ?? {} const { data: nativeBalance } = useBalance({ @@ -48,21 +56,51 @@ export default function DeployStrategyDialog({ const [kandelAddress, setKandelAddress] = React.useState("") - const { - mutate: approveKandelStrategy, - isPending: isApprovingKandelStrategy, - } = useApproveKandelStrategy({ - setKandelAddress: (address) => setKandelAddress(address), - }) + const { mutate: createKandelStrategy, isPending: isCreatingKandelStrategy } = + useCreateKandelStrategy({ + setKandelAddress: (address) => setKandelAddress(address), + }) + + const approveBaseToken = useInfiniteApproveToken() + const approveQuoteToken = useInfiniteApproveToken() + const activateSmartRouter = useActivateStrategySmartRouter(kandelAddress) const { mutate: launchKandelStrategy, isPending: isLaunchingKandelStrategy } = useLaunchKandelStrategy() + const logics = mangrove ? Object.values(mangrove.logics) : [] + + const baseLogic = logics.find((logic) => logic?.id === strategy?.sendFrom) + const quoteLogic = logics.find((logic) => logic?.id === strategy?.receiveTo) + + const { data: spender } = useSpenderAddress("kandel") + + const { data: baseTokenApproved } = useIsTokenInfiniteAllowance( + baseToken, + spender, + baseLogic, + ) + + const { data: quoteTokenApproved } = useIsTokenInfiniteAllowance( + baseToken, + spender, + quoteLogic, + ) + + const { isBound } = useStrategySmartRouter({ kandelAddress }).data ?? {} + let steps = [ "Summary", - `Approve ${baseToken?.symbol}/${quoteToken?.symbol}`, - "Deposit", - ] + "Create strategy instance", + !isBound ? "Activate router" : "", + // TODO: apply liquidity sourcing with setLogics + // TODO: if sendFrom v3 logic selected then it'll the same it the other side for receive + // TODO: if erc721 approval, add select field with available nft ids then nft.approveForAll + !baseTokenApproved ? `Approve ${baseToken?.symbol}` : "", + !quoteTokenApproved ? `Approve ${quoteToken?.symbol}` : "", + "Launch strategy", + ].filter(Boolean) + const [currentStep, helpers] = useStep(steps.length) const { goToNextStep, reset } = helpers const stepInfos = [ @@ -82,28 +120,99 @@ export default function DeployStrategyDialog({ ), }, { + body: ( +
    +
    + Create kandel instance +
    +

    + Allow Mangrove to create your kandel instance? +

    +

    + By granting permission, you are allowing the following contract to + access your funds. +

    +
    + ), + button: ( + + ), + }, + + !isBound && { + body: , + button: ( + + ), + }, + + !baseTokenApproved && { body: (
    - +
    ), button: ( + ), + }, + !quoteTokenApproved && { + body: ( +
    + +
    + ), + button: ( + ), }, @@ -138,7 +247,7 @@ export default function DeployStrategyDialog({ distribution, bountyDeposit, stepSize, - pricePoints, + numberOfOffers, } = strategy launchKandelStrategy( @@ -149,7 +258,9 @@ export default function DeployStrategyDialog({ distribution, bountyDeposit, stepSize, - pricePoints, + numberOfOffers, + baseLogic, + quoteLogic, }, { onSuccess: () => { @@ -235,8 +346,7 @@ const Summary = ({ const { baseDeposit, quoteDeposit, - ratio, - pricePoints, + numberOfOffers, stepSize, bountyDeposit, priceRange, @@ -271,7 +381,7 @@ const Summary = ({ value={
    - {Number(baseDeposit).toFixed(baseToken?.decimals) || 0} + {Number(baseDeposit).toFixed(baseToken?.displayedDecimals) || 0} {baseToken?.symbol}
    @@ -283,7 +393,8 @@ const Summary = ({ value={
    - {Number(quoteDeposit).toFixed(quoteToken?.decimals) || 0} + {Number(quoteDeposit).toFixed(quoteToken?.displayedDecimals) || + 0} {quoteToken?.symbol} @@ -298,7 +409,7 @@ const Summary = ({ title={`Min price`} value={
    - {minPrice} + {minPrice?.toFixed(quoteToken?.displayedDecimals)} {quoteToken?.symbol} @@ -310,7 +421,7 @@ const Summary = ({ title={`Max price`} value={
    - {maxPrice} + {maxPrice?.toFixed(quoteToken?.displayedDecimals)} {quoteToken?.symbol} @@ -321,10 +432,9 @@ const Summary = ({
    {pricePoints}} + title={`No. of offers`} + value={{numberOfOffers}} /> - {ratio}} /> {stepSize}} />
    diff --git a/app/strategies/new/_components/price-range/components/price-chart/background-rectangles.tsx b/app/strategies/new/_components/price-range/components/price-chart/background-rectangles.tsx index 98ea835c..a12b6bbc 100644 --- a/app/strategies/new/_components/price-range/components/price-chart/background-rectangles.tsx +++ b/app/strategies/new/_components/price-range/components/price-chart/background-rectangles.tsx @@ -11,6 +11,10 @@ type Props = { midPrice?: number | null } +function isPositiveNumber(value: number | null | undefined): value is number { + return value !== null && value !== undefined && value > 0 +} + export function BackgroundRectangles({ height, paddingBottom, @@ -37,29 +41,27 @@ export function BackgroundRectangles({ const rectHeight = height - paddingBottom + const leftX = leftBidBound && xScaleTransformed(leftBidBound) + const rightX = rightBidBound && xScaleTransformed(rightBidBound) + + if (!(leftX && rightX && isPositiveNumber(rectHeight))) return null + return ( <> {priceRange && midPrice ? ( <> - {leftBidBound && rightBidBound && ( - <> - - - - )} + + {leftAskBound && rightAskBound && ( <>
    -
    @@ -324,10 +323,11 @@ export const PriceRange = withClientOnly(function ({ baseDeposit, quoteDeposit, priceRange, - pricePoints, - ratio, + numberOfOffers, stepSize, bountyDeposit, + sendFrom, + receiveTo, }} isOpen={summaryDialog} onClose={() => setSummaryDialog(false)} diff --git a/app/strategies/new/_hooks/use-approve-kandel-strategy.ts b/app/strategies/new/_hooks/use-approve-kandel-strategy.ts index 8b803965..4b9fe05d 100644 --- a/app/strategies/new/_hooks/use-approve-kandel-strategy.ts +++ b/app/strategies/new/_hooks/use-approve-kandel-strategy.ts @@ -9,7 +9,7 @@ import { NewStratStore } from "../_stores/new-strat.store" type FormValues = Pick -export function useApproveKandelStrategy({ +export function useCreateKandelStrategy({ setKandelAddress, }: { setKandelAddress: (address: string) => void @@ -17,29 +17,20 @@ export function useApproveKandelStrategy({ const { market } = useMarket() const { kandelStrategies } = useKandel() return useMutation({ - mutationFn: async ({ baseDeposit, quoteDeposit }: FormValues) => { + mutationFn: async () => { try { if (!(market && kandelStrategies)) return const { result } = await kandelStrategies.seeder.sow({ market, - onAave: false, + type: "smart", liquiditySharing: false, }) const kandelInstance = await result - const approvalTxs = await kandelInstance.approveIfHigher( - Number(baseDeposit), - Number(quoteDeposit), - ) - - // waiting for all approvals - await Promise.all(approvalTxs.map((tx) => tx?.wait())) - setKandelAddress(kandelInstance.address) - - toast.success("Kandel strategy successfully approved") + toast.success("Kandel strategy instance successfully created") } catch (error) { const { description } = getTitleDescriptionErrorMessages(error as Error) toast.error(description) diff --git a/app/strategies/new/_hooks/use-kandel-requirements.tsx b/app/strategies/new/_hooks/use-kandel-requirements.tsx index 5643a21b..3c4c5159 100644 --- a/app/strategies/new/_hooks/use-kandel-requirements.tsx +++ b/app/strategies/new/_hooks/use-kandel-requirements.tsx @@ -12,8 +12,9 @@ export type Params = { stepSize: number | string minPrice: BigSource maxPrice: BigSource - pricePoints: number | string - ratio?: number | string + availableBase?: BigSource + availableQuote?: BigSource + numberOfOffers: number | string isChangingFrom?: ChangingFrom } @@ -21,10 +22,10 @@ export function useKandelRequirements({ onAave = false, minPrice, maxPrice, + availableBase, + availableQuote, stepSize, - pricePoints, - ratio, - isChangingFrom, + numberOfOffers, }: Params) { const { market, midPrice } = useMarket() const { kandelStrategies, generator, config } = useKandel() @@ -35,11 +36,10 @@ export function useKandelRequirements({ maxPrice, midPrice, stepSize, - pricePoints, + numberOfOffers, onAave, market?.base.id, market?.quote?.id, - ratio, ], queryFn: async () => { if ( @@ -60,14 +60,14 @@ export function useKandelRequirements({ await kandelStrategies.seeder.getMinimumVolume({ market, offerType: "asks", - onAave, + type: "smart", }) const minimumQuotePerOffer = await kandelStrategies.seeder.getMinimumVolume({ market, offerType: "bids", - onAave, + type: "smart", }) const param: Parameters< @@ -81,9 +81,7 @@ export function useKandelRequirements({ maxPrice, stepSize: Number(stepSize) ?? config.stepSize, midPrice, - pricePoints: - isChangingFrom !== "ratio" ? Number(pricePoints) : undefined, - priceRatio: isChangingFrom === "ratio" ? Number(ratio) : undefined, + pricePoints: Number(numberOfOffers) + 1, // number of offers = price points - 1 }, } @@ -95,14 +93,11 @@ export function useKandelRequirements({ const { requiredBase, requiredQuote } = minimumDistribution.getOfferedVolumeForDistribution() - const availableBase = requiredBase - const availableQuote = requiredQuote - const distribution = await generator.recalculateDistributionFromAvailable({ distribution: minimumDistribution, - availableBase, - availableQuote, + availableBase: availableBase ? availableBase : requiredBase, + availableQuote: availableQuote ? availableQuote : requiredQuote, }) const offers = distribution.getOffersWithPrices() @@ -115,7 +110,7 @@ export function useKandelRequirements({ const requiredBounty = await kandelStrategies.seeder.getRequiredProvision( { - onAave, + type: "smart", market, liquiditySharing: false, }, @@ -128,11 +123,11 @@ export function useKandelRequirements({ requiredBounty, distribution, offersWithPrices, - priceRatio: minimumDistribution.getPriceRatio(), pricePoints: minimumDistribution.pricePoints, } } catch (e) { const message = getErrorMessage(e) + console.error("Error: ", message) if (message.includes("revert")) { throw new Error(`Error: one of the parameters is invalid`) } diff --git a/app/strategies/new/_hooks/use-launch-kandel-strategy.ts b/app/strategies/new/_hooks/use-launch-kandel-strategy.ts index d2617cbb..d909420d 100644 --- a/app/strategies/new/_hooks/use-launch-kandel-strategy.ts +++ b/app/strategies/new/_hooks/use-launch-kandel-strategy.ts @@ -4,20 +4,31 @@ import { useRouter } from "next/navigation" import { toast } from "sonner" import useKandel from "@/app/strategies/(list)/_providers/kandel-strategies" + +import useMangrove from "@/providers/mangrove" import useMarket from "@/providers/market" import { getTitleDescriptionErrorMessages } from "@/utils/tx-error-messages" +import { DefaultStrategyLogics } from "../../(shared)/type" import { NewStratStore } from "../_stores/new-strat.store" type FormValues = Pick< NewStratStore, - "baseDeposit" | "quoteDeposit" | "pricePoints" | "stepSize" | "bountyDeposit" + | "baseDeposit" + | "quoteDeposit" + | "numberOfOffers" + | "stepSize" + | "bountyDeposit" > & { distribution: GeometricKandelDistribution | undefined kandelAddress: string + baseLogic: DefaultStrategyLogics + quoteLogic: DefaultStrategyLogics } export function useLaunchKandelStrategy() { const { market } = useMarket() + const { mangrove } = useMangrove() + const { kandelStrategies } = useKandel() const router = useRouter() @@ -28,24 +39,39 @@ export function useLaunchKandelStrategy() { distribution, bountyDeposit, stepSize, - pricePoints, + numberOfOffers, kandelAddress, + baseLogic, + quoteLogic, }: FormValues) => { try { if (!(market && kandelStrategies && distribution)) return + // const _baseLogic = mangrove?.getLogicByAddress(baseLogic.address) + // const _quoteLogic = mangrove?.getLogicByAddress(quoteLogic.address) + + // if (!_quoteLogic || !_baseLogic) return + const kandelInstance = await kandelStrategies.instance({ address: kandelAddress, market, + type: "smart", }) + // console.log("setting logic...") + + // await kandelInstance.setLogics({ + // baseLogic: _baseLogic, + // quoteLogic: _quoteLogic, + // }) + const populateTxs = await kandelInstance.populateGeometricDistribution({ distribution, - depositBaseAmount: baseDeposit, - depositQuoteAmount: quoteDeposit, + // depositBaseAmount: baseDeposit, + // depositQuoteAmount: quoteDeposit, funds: bountyDeposit, parameters: { - pricePoints: Number(pricePoints), + pricePoints: Number(numberOfOffers) + 1, stepSize: Number(stepSize), }, }) diff --git a/app/strategies/new/_stores/new-strat.store.ts b/app/strategies/new/_stores/new-strat.store.ts index fcb52c67..419be706 100644 --- a/app/strategies/new/_stores/new-strat.store.ts +++ b/app/strategies/new/_stores/new-strat.store.ts @@ -13,21 +13,24 @@ export type ChangingFrom = | "chart" | "baseDeposit" | "quoteDeposit" - | "pricePoints" - | "ratio" + | "numberOfOffers" | "stepSize" | "bountyDeposit" + | "sendFrom" + | "receiveTo" | undefined | null export type NewStratStore = { baseDeposit: string quoteDeposit: string - pricePoints: string - ratio: string + numberOfOffers: string stepSize: string bountyDeposit: string + sendFrom: string + receiveTo: string + priceRange: [string, string] offersWithPrices?: OffersWithPrices @@ -41,10 +44,11 @@ export type NewStratStore = { type NewStratActions = { setBaseDeposit: (baseDeposit: string) => void setQuoteDeposit: (quoteDeposit: string) => void - setPricePoints: (pricePoints: string) => void - setRatio: (ratio: string) => void + setNumberOfOffers: (numberOfOffers: string) => void setStepSize: (stepSize: string) => void setBountyDeposit: (bountyDeposit: string) => void + setSendFrom: (source: string) => void + setReceiveTo: (source: string) => void setPriceRange: (min: string, max: string) => void setOffersWithPrices: (offersWithPrices?: OffersWithPrices) => void @@ -61,11 +65,13 @@ const newStratStateCreator: StateCreator = ( ) => ({ baseDeposit: "", quoteDeposit: "", - pricePoints: "10", - ratio: "", + numberOfOffers: "10", stepSize: "1", bountyDeposit: "", + sendFrom: "simple", + receiveTo: "simple", + priceRange: ["", ""], offersWithPrices: undefined, @@ -77,11 +83,13 @@ const newStratStateCreator: StateCreator = ( setBaseDeposit: (baseDeposit) => set({ baseDeposit }), setQuoteDeposit: (quoteDeposit) => set({ quoteDeposit }), - setPricePoints: (pricePoints) => set({ pricePoints }), - setRatio: (ratio) => set({ ratio }), + setNumberOfOffers: (numberOfOffers) => set({ numberOfOffers }), setStepSize: (stepSize) => set({ stepSize }), setBountyDeposit: (bountyDeposit) => set({ bountyDeposit }), + setSendFrom: (sendFrom) => set({ sendFrom }), + setReceiveTo: (receiveTo) => set({ receiveTo }), + setPriceRange: (min, max) => set({ priceRange: [min, max] }), setOffersWithPrices: (offersWithPrices) => set({ offersWithPrices }), diff --git a/app/strategies/new/layout.tsx b/app/strategies/new/layout.tsx index d87a6351..17863771 100644 --- a/app/strategies/new/layout.tsx +++ b/app/strategies/new/layout.tsx @@ -1,3 +1,4 @@ +import { Metadata } from "next" import React from "react" import { KandelStrategiesProvider } from "@/app/strategies/(list)/_providers/kandel-strategies" @@ -5,6 +6,11 @@ import { Navbar } from "@/components/navbar" import { IndexerSdkProvider } from "@/providers/mangrove-indexer" import { MarketProvider } from "@/providers/market" +export const metadata: Metadata = { + title: "New Strategy | Mangrove DEX", + description: "New Strategy on Mangrove DEX", +} + export default function Layout({ children }: React.PropsWithChildren) { return ( diff --git a/app/trade/_components/charts/depth-chart/depth-chart.tsx b/app/trade/_components/charts/depth-chart/depth-chart.tsx index f33cb751..680b1514 100644 --- a/app/trade/_components/charts/depth-chart/depth-chart.tsx +++ b/app/trade/_components/charts/depth-chart/depth-chart.tsx @@ -85,7 +85,7 @@ export function DepthChart() { & { selectedToken?: Token - selectedSource?: SimpleLogic | SimpleAaveLogic | OrbitLogic + selectedSource?: DefaultTradeLogics sendAmount: string assetsWithTokens: AssetWithInfos[] } @@ -61,21 +59,13 @@ export default function FromWalletAmplifiedOrderDialog({ const { isDeployed, isBound } = useSmartRouter().data ?? {} - let steps = [] as string[] - - if (!isInfiniteAllowance) { - steps = ["Summary", `Approve ${selectedToken?.symbol}`, ...steps] - } - - if (isDeployed) { - steps = [...steps, "Amplified order deployment"] - } - - if (!isBound) { - steps = [...steps, "Amplified order activation"] - } - - steps = [...steps, "Send"] + let steps = [ + "Summary", + !isInfiniteAllowance ? `Approve ${selectedToken?.symbol}` : "", + isDeployed ? "Amplified order deployment" : "", + !isBound ? "Amplified order activation" : "", + "Send", + ].filter(Boolean) const isDialogOpenRef = React.useRef(false) React.useEffect(() => { diff --git a/app/trade/_components/forms/amplified/components/summary-step.tsx b/app/trade/_components/forms/amplified/components/summary-step.tsx index ada3eda4..03a49042 100644 --- a/app/trade/_components/forms/amplified/components/summary-step.tsx +++ b/app/trade/_components/forms/amplified/components/summary-step.tsx @@ -1,13 +1,11 @@ import type { Token } from "@mangrovedao/mangrove.js" -import { SimpleAaveLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/SimpleAaveLogic" -import { SimpleLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/SimpleLogic" import Big from "big.js" import { TokenIcon } from "@/components/token-icon" import { Text } from "@/components/typography/text" import { Separator } from "@/components/ui/separator" import { cn } from "@/utils" -import { OrbitLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/OrbitLogic" +import { DefaultTradeLogics } from "../../types" import { TimeInForce } from "../enums" import type { AssetWithInfos, Form } from "../types" @@ -15,7 +13,7 @@ type Props = { form: Omit tokenToAmplify?: Token sendAmount: string - source: SimpleLogic | SimpleAaveLogic | OrbitLogic + source: DefaultTradeLogics assetsWithToken?: AssetWithInfos[] } diff --git a/app/trade/_components/forms/amplified/hooks/amplified-liquidity-sourcing.ts b/app/trade/_components/forms/amplified/hooks/amplified-liquidity-sourcing.ts index e20adbbe..e6990b37 100644 --- a/app/trade/_components/forms/amplified/hooks/amplified-liquidity-sourcing.ts +++ b/app/trade/_components/forms/amplified/hooks/amplified-liquidity-sourcing.ts @@ -1,10 +1,10 @@ import { Token } from "@mangrovedao/mangrove.js" import React from "react" -import { DefaultLogics } from "../../types" +import { DefaultTradeLogics } from "../../types" type Props = { sendFrom?: string - logics: DefaultLogics[] + logics: DefaultTradeLogics[] fundOwner?: string sendToken?: Token availableTokens?: Token[] diff --git a/app/trade/_components/forms/amplified/hooks/use-amplified.ts b/app/trade/_components/forms/amplified/hooks/use-amplified.ts index 4383f6f3..202d749b 100644 --- a/app/trade/_components/forms/amplified/hooks/use-amplified.ts +++ b/app/trade/_components/forms/amplified/hooks/use-amplified.ts @@ -7,8 +7,9 @@ import useMangrove from "@/providers/mangrove" import useMarket from "@/providers/market" import Big from "big.js" import { useEventListener } from "usehooks-ts" +import { DefaultTradeLogics } from "../../types" import { TimeInForce, TimeToLiveUnit } from "../enums" -import { Asset } from "../types" +import { Asset, AssetWithInfos } from "../types" import { getCurrentTokenPrice } from "../utils" import amplifiedLiquiditySourcing from "./amplified-liquidity-sourcing" import { ChangingFrom, useNewStratStore } from "./amplified-store" @@ -85,14 +86,20 @@ export default function useAmplifiedForm() { return acc }, [] as Token[]) ?? [] - const logics = mangrove ? Object.values(mangrove.logics) : [] + const logics = ( + mangrove + ? Object.values(mangrove.logics).filter( + (item) => item?.approvalType !== "ERC721", + ) + : [] + ) as DefaultTradeLogics[] const tickSize = marketInfo?.tickSpacing ? `${((1.0001 ** marketInfo?.tickSpacing - 1) * 100).toFixed(2)}%` : "" const selectedToken = availableTokens.find((token) => token.id == sendToken) - const selectedSource = logics.find((logic) => logic?.id == sendSource) + const selectedSource = logics?.find((logic) => logic?.id == sendSource) const compatibleMarkets = openMarkets?.filter( (market) => @@ -245,40 +252,34 @@ export default function useAmplifiedForm() { } React.useEffect(() => { - let newMinVolume = 0 - let minVolume = 0 const selectedSourceGasOverhead = selectedSource?.gasOverhead || 200_000 const variableCost = Big(60_000).mul(assetsWithTokens.length).add(60_000) - let gasreq = Big(0) - for (const asset of assetsWithTokens) { - gasreq = Big( - Math.max( - asset.receiveTo?.gasOverhead || 0, - selectedSource?.gasOverhead || 0, - ), - ).add(variableCost) - } - const semibookAsks = market?.getSemibook("asks") const semibookBids = market?.getSemibook("bids") const isBid = openMarkets?.find( (market) => market.base.id === sendToken, )?.quote const priceToken = isBid - const getMinimumVolume = (semibook?: Semibook) => { + + const calculateGasReq = (asset: AssetWithInfos) => { + return Big( + Math.max(asset.receiveTo?.gasOverhead || 0, selectedSourceGasOverhead), + ).add(variableCost) + } + + const getMinimumVolume = (gasreq: Big, semibook?: Semibook) => { return Number(semibook?.getMinimumVolume(gasreq.toNumber()) || 0) } - const calculateMinVolume = (asset: Asset) => { + const calculateMinVolume = (asset: AssetWithInfos, semibook?: Semibook) => { if (!asset.token || !asset.limitPrice) return 0 - const semibook = isBid ? semibookAsks : semibookBids - return getMinimumVolume(semibook) + return getMinimumVolume(calculateGasReq(asset), semibook) } - assets.forEach((asset) => { - minVolume = calculateMinVolume(asset) - newMinVolume += minVolume - }) + const newMinVolume = assetsWithTokens.reduce((acc, asset) => { + const semibook = isBid ? semibookAsks : semibookBids + return acc + calculateMinVolume(asset, semibook) + }, 0) setMinVolume({ total: newMinVolume.toFixed( @@ -286,7 +287,7 @@ export default function useAmplifiedForm() { ? selectedToken?.displayedDecimals : priceToken?.displayedDecimals, ), - volume: minVolume.toFixed(selectedToken?.displayedDecimals), + volume: newMinVolume.toFixed(selectedToken?.displayedDecimals), }) }, [assets, sendToken]) diff --git a/app/trade/_components/forms/amplified/hooks/use-post-amplified-order.ts b/app/trade/_components/forms/amplified/hooks/use-post-amplified-order.ts index 06e4a992..a0c9f8f8 100644 --- a/app/trade/_components/forms/amplified/hooks/use-post-amplified-order.ts +++ b/app/trade/_components/forms/amplified/hooks/use-post-amplified-order.ts @@ -4,9 +4,9 @@ import { Token, } from "@mangrovedao/mangrove.js" import { OrbitLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/OrbitLogic" -import { SimpleAaveLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/SimpleAaveLogic" import { SimpleLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/SimpleLogic" import { useMutation, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" import { parseUnits } from "viem" import { TRADE } from "@/app/trade/_constants/loading-keys" @@ -15,9 +15,11 @@ import useMangrove from "@/providers/mangrove" import useMarket from "@/providers/market" import { useLoadingStore } from "@/stores/loading.store" import { TransactionReceipt } from "@ethersproject/providers" -import { ZeroLendLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/ZeroLendLogic" -import { toast } from "sonner" -import { DefaultLogics } from "../../types" + +import { PacFinanceLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/AaveV3/PacFinanceLogic" +import { SimpleAaveLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/AaveV3/SimpleAaveLogic" +import { ZeroLendLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/AaveV3/ZeroLendLogic" +import { DefaultTradeLogics } from "../../types" import { TimeInForce } from "../enums" import type { AssetWithInfos, Form } from "../types" import { estimateTimestamp } from "../utils" @@ -69,7 +71,7 @@ export function usePostAmplifiedOrder({ onResult }: Props = {}) { type Assets = { inboundToken: string | undefined - inboundLogic: DefaultLogics + inboundLogic: DefaultTradeLogics tickSpacing: number tick: number }[] @@ -82,6 +84,7 @@ export function usePostAmplifiedOrder({ onResult }: Props = {}) { | SimpleAaveLogic | OrbitLogic | ZeroLendLogic + | PacFinanceLogic inboundToken: string } => { return ( diff --git a/app/trade/_components/forms/amplified/types.ts b/app/trade/_components/forms/amplified/types.ts index a6fe8b85..e7638860 100644 --- a/app/trade/_components/forms/amplified/types.ts +++ b/app/trade/_components/forms/amplified/types.ts @@ -1,7 +1,6 @@ import { Token } from "@mangrovedao/mangrove.js" -import { OrbitLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/OrbitLogic" -import { SimpleAaveLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/SimpleAaveLogic" -import { SimpleLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/SimpleLogic" + +import { DefaultTradeLogics } from "../types" import { TimeInForce, TimeToLiveUnit } from "./enums" export type Asset = { @@ -11,6 +10,13 @@ export type Asset = { receiveTo: string } +export type AssetWithInfos = { + token: Token | undefined + receiveTo: DefaultTradeLogics + amount: string + limitPrice: string +} + export type Form = { sendSource: string sendAmount: string @@ -20,10 +26,3 @@ export type Form = { timeToLive: string timeToLiveUnit: TimeToLiveUnit } - -export type AssetWithInfos = { - token: Token | undefined - receiveTo: SimpleLogic | SimpleAaveLogic | OrbitLogic | undefined - amount: string - limitPrice: string -} diff --git a/app/trade/_components/forms/hooks/use-spender-address.ts b/app/trade/_components/forms/hooks/use-spender-address.ts index 9d451dad..2fa0a873 100644 --- a/app/trade/_components/forms/hooks/use-spender-address.ts +++ b/app/trade/_components/forms/hooks/use-spender-address.ts @@ -1,7 +1,9 @@ import useMangrove from "@/providers/mangrove" import { useQuery } from "@tanstack/react-query" -export const useSpenderAddress = (type: "limit" | "market" | "amplified") => { +export const useSpenderAddress = ( + type: "kandel" | "limit" | "market" | "amplified", +) => { const { mangrove } = useMangrove() return useQuery({ // eslint-disable-next-line @tanstack/query/exhaustive-deps diff --git a/app/trade/_components/forms/limit/components/from-wallet-order-dialog.tsx b/app/trade/_components/forms/limit/components/from-wallet-order-dialog.tsx index 6c4595e3..68e04ed6 100644 --- a/app/trade/_components/forms/limit/components/from-wallet-order-dialog.tsx +++ b/app/trade/_components/forms/limit/components/from-wallet-order-dialog.tsx @@ -1,10 +1,6 @@ import React from "react" import { useAccount } from "wagmi" -import { OrbitLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/OrbitLogic" -import { SimpleAaveLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/SimpleAaveLogic" -import { SimpleLogic } from "@mangrovedao/mangrove.js/dist/nodejs/logics/SimpleLogic" - import { tradeService } from "@/app/trade/_services/trade.service" import Dialog from "@/components/dialogs/dialog" import { Button, type ButtonProps } from "@/components/ui/button" @@ -17,13 +13,14 @@ import { ApproveStep } from "../../components/approve-step" import { MarketDetails } from "../../components/market-details" import { Steps } from "../../components/steps" import { useTradeInfos } from "../../hooks/use-trade-infos" +import { DefaultTradeLogics } from "../../types" import { usePostLimitOrder } from "../hooks/use-post-limit-order" import type { Form } from "../types" import { SummaryStep } from "./summary-step" type Props = { form: Form & { - selectedSource?: SimpleLogic | SimpleAaveLogic | OrbitLogic + selectedSource?: DefaultTradeLogics minVolume: { bid: { volume: string | undefined @@ -143,7 +140,6 @@ export default function FromWalletLimitOrderDialog({ form, onClose }: Props) { approve.mutate( { token: sendToken, - //@ts-ignore logic, spender, }, diff --git a/app/trade/_components/forms/limit/hooks/liquidity-sourcing.ts b/app/trade/_components/forms/limit/hooks/liquidity-sourcing.ts index 02be6146..1243be9a 100644 --- a/app/trade/_components/forms/limit/hooks/liquidity-sourcing.ts +++ b/app/trade/_components/forms/limit/hooks/liquidity-sourcing.ts @@ -1,11 +1,11 @@ import { Token } from "@mangrovedao/mangrove.js" import React from "react" -import { DefaultLogics } from "../../types" +import { DefaultTradeLogics } from "../../types" type Props = { sendFrom: string receiveTo: string - logics: DefaultLogics[] + logics: DefaultTradeLogics[] fundOwner?: string sendToken?: Token receiveToken?: Token @@ -17,7 +17,7 @@ type BalanceLogic = { } // export function useAbleToken( -// logic: DefaultLogics, +// logic: DefaultTradeLogics, // token: Token, // ) { // return useQuery({ @@ -45,10 +45,11 @@ export default function liquiditySourcing({ BalanceLogic | undefined >() - const [sendFromLogics, setSendFromLogics] = React.useState() + const [sendFromLogics, setSendFromLogics] = + React.useState() const [receiveToLogics, setReceiveToLogics] = - React.useState() + React.useState() const getSendFromLogics = async (token: Token) => { const usableLogics = logics.map(async (logic) => { diff --git a/app/trade/_components/forms/limit/hooks/use-limit.ts b/app/trade/_components/forms/limit/hooks/use-limit.ts index d9bf0f65..930c50ec 100644 --- a/app/trade/_components/forms/limit/hooks/use-limit.ts +++ b/app/trade/_components/forms/limit/hooks/use-limit.ts @@ -10,6 +10,7 @@ import useMangrove from "@/providers/mangrove" import useMarket from "@/providers/market" import { TradeAction } from "../../enums" import { useTradeInfos } from "../../hooks/use-trade-infos" +import { DefaultTradeLogics } from "../../types" import { TimeInForce, TimeToLiveUnit } from "../enums" import type { Form } from "../types" @@ -43,7 +44,14 @@ export function useLimit(props: Props) { const receiveTo = form.useStore((state) => state.values.receiveTo) const timeInForce = form.useStore((state) => state.values.timeInForce) - const logics = mangrove ? Object.values(mangrove.logics) : [] + const logics = ( + mangrove + ? Object.values(mangrove.logics).filter( + (item) => item?.approvalType !== "ERC721", + ) + : [] + ) as DefaultTradeLogics[] + const selectedSource = logics.find((logic) => logic?.id === sendFrom) const { diff --git a/app/trade/_components/forms/limit/hooks/use-post-limit-order.ts b/app/trade/_components/forms/limit/hooks/use-post-limit-order.ts index f593c8b6..220ad62f 100644 --- a/app/trade/_components/forms/limit/hooks/use-post-limit-order.ts +++ b/app/trade/_components/forms/limit/hooks/use-post-limit-order.ts @@ -8,6 +8,7 @@ import { useLoadingStore } from "@/stores/loading.store" import type { Market } from "@mangrovedao/mangrove.js" import { TRADEMODE_AND_ACTION_PRESENTATION } from "../../constants" import { TradeAction, TradeMode } from "../../enums" +import { DefaultTradeLogics } from "../../types" import { successToast } from "../../utils" import { TimeInForce } from "../enums" import type { Form } from "../types" @@ -46,11 +47,11 @@ export function usePostLimitOrder({ onResult }: Props = {}) { const takerGivesLogic = logics.find( (logic) => logic?.id === form.sendFrom, - ) + ) as DefaultTradeLogics const takerWantsLogic = logics.find( (logic) => logic?.id === form.receiveTo, - ) + ) as DefaultTradeLogics const restingOrderGasreq = Math.max( takerGivesLogic?.gasOverhead || 200_000, diff --git a/app/trade/_components/forms/limit/utils.tsx b/app/trade/_components/forms/limit/utils.tsx index 6be5a807..e8e79eb0 100644 --- a/app/trade/_components/forms/limit/utils.tsx +++ b/app/trade/_components/forms/limit/utils.tsx @@ -57,6 +57,14 @@ export function getFormattedTimeToLive( } export const sourceIcons: { [key: string]: JSX.Element } = { + pacFinance: ( + ZeroLend + ), zeroLend: ( offer.gives)?.gives || "0"), - form.sendToken?.decimals ?? 4, + form.sendToken?.decimals ?? 6, ), ).toFixed(form.sendToken?.displayedDecimals)}` diff --git a/app/trade/_components/tables/orders/hooks/use-amplified-orders.ts b/app/trade/_components/tables/orders/hooks/use-amplified-orders.ts index 727ffd4f..9f7c20ac 100644 --- a/app/trade/_components/tables/orders/hooks/use-amplified-orders.ts +++ b/app/trade/_components/tables/orders/hooks/use-amplified-orders.ts @@ -58,13 +58,13 @@ export function useAmplifiedOrders({ (offer) => offer.isMarketFound, ) - const atLeastOneOpenOffer = order.offers.some((offer) => offer.isOpen) + const closedOffers = order.offers.every((offer) => !offer.isOpen) const isExpired = order.expiryDate ? new Date(order.expiryDate) < new Date() : true - - return allOffersMarketFound && !isExpired && atLeastOneOpenOffer + + return allOffersMarketFound && !closedOffers && !isExpired }) return parseAmplifiedOrders(filteredResult) diff --git a/app/trade/_components/tables/orders/hooks/use-edit-amplified-order.ts b/app/trade/_components/tables/orders/hooks/use-edit-amplified-order.ts index a148f91a..c7b3f0b7 100644 --- a/app/trade/_components/tables/orders/hooks/use-edit-amplified-order.ts +++ b/app/trade/_components/tables/orders/hooks/use-edit-amplified-order.ts @@ -14,6 +14,7 @@ import { formatExpiryDate } from "@/utils/date" import { TimeInForce, TimeToLiveUnit } from "../../../forms/amplified/enums" import amplifiedLiquiditySourcing from "../../../forms/amplified/hooks/amplified-liquidity-sourcing" import { getCurrentTokenPriceFromAddress } from "../../../forms/amplified/utils" +import { DefaultTradeLogics } from "../../../forms/types" import { AmplifiedOrder } from "../schema" import { AmplifiedForm, AmplifiedOrderStatus } from "../types" @@ -30,7 +31,13 @@ export function useEditAmplifiedOrder({ order, onSubmit }: Props) { mangrove, } = useMangrove() - const logics = mangrove ? Object.values(mangrove.logics) : [] + const logics = ( + mangrove + ? Object.values(mangrove.logics).filter( + (item) => item?.approvalType !== "ERC721", + ) + : [] + ) as DefaultTradeLogics[] const sendToken = useTokenFromAddress( offers.find((offer) => offer?.market.outbound_tkn as Address)?.market diff --git a/app/trade/_components/tables/orders/orders.tsx b/app/trade/_components/tables/orders/orders.tsx index 1f2074b0..44bf5828 100644 --- a/app/trade/_components/tables/orders/orders.tsx +++ b/app/trade/_components/tables/orders/orders.tsx @@ -6,15 +6,12 @@ import useMangrove from "@/providers/mangrove" import useMarket from "@/providers/market" import CancelOfferDialog from "./components/cancel-offer-dialog" import EditOrderSheet from "./components/edit-order-sheet" -import { useAmplifiedOrders } from "./hooks/use-amplified-orders" -import { useAmplifiedTable } from "./hooks/use-amplified-table" import { useOrders } from "./hooks/use-orders" import { useTable } from "./hooks/use-table" -import type { AmplifiedOrder, Order } from "./schema" +import type { Order } from "./schema" export function Orders() { - const { marketsInfoQuery, mangrove } = useMangrove() - const { data: openMarkets } = marketsInfoQuery + const { marketsInfoQuery } = useMangrove() const [{ page, pageSize }, setPageDetails] = React.useState({ page: 1, @@ -30,28 +27,6 @@ export function Orders() { }, }) - const amplifiedOrdersQuery = useAmplifiedOrders({ - filters: { - skip: (page - 1) * pageSize, - }, - }) - - // selected order to delete - const [amplifiedOrderToDelete, setAmplifiedOrderToDelete] = - React.useState() - const [amplifiedOrderToEdit, setAmplifiedOrderToEdit] = React.useState<{ - order: AmplifiedOrder - mode: "view" | "edit" - }>() - - const amplifiedTable = useAmplifiedTable({ - data: amplifiedOrdersQuery.data, - onEdit: (order) => setAmplifiedOrderToEdit({ order, mode: "edit" }), - onCancel: setAmplifiedOrderToDelete, - }) - - // regular orders - // selected order to delete const [orderToDelete, setOrderToDelete] = React.useState() const [orderToEdit, setOrderToEdit] = React.useState<{ diff --git a/app/trade/_components/tables/orders/types.ts b/app/trade/_components/tables/orders/types.ts index edd8e97c..fac9c2cc 100644 --- a/app/trade/_components/tables/orders/types.ts +++ b/app/trade/_components/tables/orders/types.ts @@ -1,6 +1,6 @@ import { TimeInForce } from "../../forms/amplified/enums" import { TimeToLiveUnit } from "../../forms/limit/enums" -import { DefaultLogics } from "../../forms/types" +import { DefaultTradeLogics } from "../../forms/types" export type Form = { limitPrice: string @@ -22,7 +22,7 @@ export enum AmplifiedOrderStatus { export type AmplifiedForm = { send: string - sendFrom: DefaultLogics + sendFrom: DefaultTradeLogics assets: Asset[] timeInForce: TimeInForce timeToLive: string diff --git a/components/info-tooltip.tsx b/components/info-tooltip.tsx index 6fdf77f0..b6e3b9a9 100644 --- a/components/info-tooltip.tsx +++ b/components/info-tooltip.tsx @@ -3,6 +3,7 @@ import { PropsWithChildren } from "react" import { Tooltip, TooltipContent, + TooltipPortal, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" @@ -32,7 +33,9 @@ export default function InfoTooltip({ > - {children} + + {children} + ) diff --git a/components/minimum-volume.tsx b/components/minimum-volume.tsx index b1d992e2..d2e8263f 100644 --- a/components/minimum-volume.tsx +++ b/components/minimum-volume.tsx @@ -3,10 +3,11 @@ import type { Token } from "@mangrovedao/mangrove.js" import { Skeleton } from "@/components/ui/skeleton" import { Tooltip, + TooltipContent, + TooltipPortal, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" -import { TooltipContent } from "@radix-ui/react-tooltip" import Link from "next/link" import InfoTooltip from "./info-tooltip" import { Caption } from "./typography/caption" @@ -62,9 +63,12 @@ export function MinimumVolume(props: { {token?.symbol} - - {Number(props.volume).toFixed(token?.decimals)} {token?.symbol} - + + + {Number(props.volume).toFixed(token?.decimals)}{" "} + {token?.symbol} + + diff --git a/components/navbar.tsx b/components/navbar.tsx index 0a5f0ed3..65ce7f03 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -38,8 +38,7 @@ import { } from "@rainbow-me/rainbowkit" import useLocalStorage from "@/hooks/use-local-storage" -import { blast } from "@/providers/wallet-connect" -import { blastSepolia } from "viem/chains" +import { blast, blastSepolia } from "viem/chains" import UnWrapETHDialog from "./stateful/dialogs/unwrap-dialog" import WrapETHDialog from "./stateful/dialogs/wrap-dialog" import { ImageWithHideOnError } from "./ui/image-with-hide-on-error" @@ -60,27 +59,27 @@ const LINKS = [ { name: "Strategies", href: "/strategies", - disabled: true, + disabled: false, message: "Cooking...", }, { name: "Points", href: "/points", - disabled: true, - message: ( -
    - Points program is live!
    - The Points page will be available in the coming days. -
    - More info{" "} - - here - -
    - ), + disabled: false, + // message: ( + //
    + // Points program is live!
    + // The Points page will be available in the coming days. + //
    + // More info{" "} + // + // here + // + //
    + // ), }, { name: "Referrals", diff --git a/components/stateful/token-balance/custom-balance.tsx b/components/stateful/token-balance/custom-balance.tsx index 81559c7a..1bd96340 100644 --- a/components/stateful/token-balance/custom-balance.tsx +++ b/components/stateful/token-balance/custom-balance.tsx @@ -3,10 +3,11 @@ import type { Token } from "@mangrovedao/mangrove.js" import { Skeleton } from "@/components/ui/skeleton" import { Tooltip, + TooltipContent, + TooltipPortal, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" -import { TooltipContent } from "@radix-ui/react-tooltip" export function CustomBalance(props: { token?: Token | string @@ -36,7 +37,7 @@ export function CustomBalance(props: { e.stopPropagation() e.preventDefault() - props.action?.onClick(props.balance || "0") + props.action?.onClick(props.balance || "") }} > @@ -50,9 +51,17 @@ export function CustomBalance(props: { )} - - {Number(props.balance).toFixed(token?.decimals)} {token?.symbol} - + + { + e.stopPropagation() + e.preventDefault() + }} + > + {Number(props.balance).toFixed(token?.decimals)}{" "} + {token?.symbol} + + diff --git a/components/stateful/token-balance/token-balance.tsx b/components/stateful/token-balance/token-balance.tsx index 5760cbcf..bba5d26c 100644 --- a/components/stateful/token-balance/token-balance.tsx +++ b/components/stateful/token-balance/token-balance.tsx @@ -4,6 +4,7 @@ import { Skeleton } from "@/components/ui/skeleton" import { Tooltip, TooltipContent, + TooltipPortal, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" @@ -35,20 +36,22 @@ export function TokenBalance(props: { onClick={(e) => { e.stopPropagation() e.preventDefault() - props.action?.onClick(formatted || "0") + props.action?.onClick(formatted || "") }} > {formattedWithSymbol} - { - e.stopPropagation() - e.preventDefault() - }} - > - {Number(formatted).toFixed(token?.decimals)} {token?.symbol} - + + + { + e.stopPropagation() + e.preventDefault() + }} + > + {Number(formatted).toFixed(token?.decimals)} {token?.symbol} + + {props?.action && props?.action.text && ( diff --git a/components/ui/data-table/data-table.tsx b/components/ui/data-table/data-table.tsx index 5ac7821e..5f10a02c 100644 --- a/components/ui/data-table/data-table.tsx +++ b/components/ui/data-table/data-table.tsx @@ -25,6 +25,7 @@ interface DataTableProps { onRowClick?: (row: TData | null) => void renderExtraRow?: (row: Row) => React.ReactNode tableRowClasses?: string + skeletonRows?: number } export function DataTable({ @@ -34,9 +35,10 @@ export function DataTable({ pagination, isRowHighlighted = () => false, onRowHover = () => {}, - onRowClick = () => {}, + onRowClick, renderExtraRow = () => null, tableRowClasses, + skeletonRows = 2, }: DataTableProps) { const rows = table.getRowModel().rows const leafColumns = table @@ -66,17 +68,17 @@ export function DataTable({ {isLoading ? ( - + ) : rows?.length ? ( rows.map((row) => ( <> { try { @@ -20,9 +19,13 @@ export function useInfiniteApproveToken() { if (logic) { try { const tokenToApprove = await logic.overlying(token) - - const result = await tokenToApprove.approve(spender) - return result.wait() + if (tokenToApprove instanceof Token) { + const result = await tokenToApprove.approve(spender) + return result.wait() + } else { + // TODO: implement logic for erc721 + return + } } catch (error) { return } diff --git a/hooks/use-is-token-infinite-allowance.ts b/hooks/use-is-token-infinite-allowance.ts index cfe1239c..ab320e93 100644 --- a/hooks/use-is-token-infinite-allowance.ts +++ b/hooks/use-is-token-infinite-allowance.ts @@ -1,11 +1,13 @@ -import { DefaultLogics } from "@/app/trade/_components/forms/types" -import type { Token } from "@mangrovedao/mangrove.js" +import { Token } from "@mangrovedao/mangrove.js" import { useQuery } from "@tanstack/react-query" +import { DefaultStrategyLogics } from "@/app/strategies/(shared)/type" +import { DefaultTradeLogics } from "@/app/trade/_components/forms/types" + export const useIsTokenInfiniteAllowance = ( token?: Token, spender?: string | null, - logic?: DefaultLogics, + logic?: DefaultTradeLogics | DefaultStrategyLogics, ) => { return useQuery({ queryKey: ["isTokenInfiniteAllowance", token?.id, spender, logic?.id], @@ -13,7 +15,12 @@ export const useIsTokenInfiniteAllowance = ( if (!(token && spender)) return null if (logic) { const tokenToApprove = await logic.overlying(token) - return await tokenToApprove.allowanceInfinite({ spender }) + if (tokenToApprove instanceof Token) { + return await tokenToApprove.allowanceInfinite({ spender }) + } else { + // TODO: erc721 approve for all + return + } } else { return token.allowanceInfinite({ spender }) } diff --git a/hooks/use-liquidity-infinite-allowance.ts b/hooks/use-liquidity-infinite-allowance.ts index 6c4a830a..516a48b5 100644 --- a/hooks/use-liquidity-infinite-allowance.ts +++ b/hooks/use-liquidity-infinite-allowance.ts @@ -1,18 +1,25 @@ -import { DefaultLogics } from "@/app/trade/_components/forms/types" -import type { Token } from "@mangrovedao/mangrove.js" +import { Token } from "@mangrovedao/mangrove.js" import { useQuery } from "@tanstack/react-query" +import { DefaultStrategyLogics } from "@/app/strategies/(shared)/type" +import { DefaultTradeLogics } from "@/app/trade/_components/forms/types" + export const useIsLiquidityInfiniteAllowance = ( token?: Token, spender?: string | null, - logic?: DefaultLogics, + logic?: DefaultTradeLogics | DefaultStrategyLogics, ) => { return useQuery({ queryKey: ["isTokenInfiniteAllowance", token?.id, spender, logic?.id], queryFn: async () => { if (!(token && spender && logic)) return null const tokenToApprove = await logic.overlying(token) - return await tokenToApprove.allowanceInfinite({ spender }) + if (tokenToApprove instanceof Token) { + return await tokenToApprove.allowanceInfinite({ spender }) + } else { + // TODO: erc721 approve for all + return + } }, enabled: !!(token && spender && logic), meta: { diff --git a/hooks/use-token-balance.ts b/hooks/use-token-balance.ts index 631883db..0021c3e1 100644 --- a/hooks/use-token-balance.ts +++ b/hooks/use-token-balance.ts @@ -14,7 +14,7 @@ export function useTokenBalance(token?: Token) { formattedWithSymbol: data && `${Number(data?.formatted).toFixed( - token?.displayedDecimals ?? 4, + token?.displayedDecimals ?? 6, )} ${data?.symbol}`, ...rest, } diff --git a/package.json b/package.json index ed923a24..544d0d1a 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,9 @@ "@ethersproject/providers": "^5.7.2", "@gsap/react": "^2.0.2", "@mangrovedao/context-addresses": "^1.3.4", - "@mangrovedao/indexer-sdk": "0.0.11-42", - "@mangrovedao/mangrove-deployments": "2.2.3", - "@mangrovedao/mangrove.js": "2.0.5-43", + "@mangrovedao/indexer-sdk": "0.0.11-44", + "@mangrovedao/mangrove-deployments": "2.2.4-5", + "@mangrovedao/mangrove.js": "2.0.5-48", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", @@ -40,7 +40,7 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", - "@rainbow-me/rainbowkit": "2", + "@rainbow-me/rainbowkit": "^2.0.1", "@sentry/nextjs": "^7.93.0", "@storybook/preview-api": "^7.6.8", "@t3-oss/env-nextjs": "^0.6.1", @@ -83,7 +83,7 @@ "tailwindcss-animate": "^1.0.7", "use-resize-observer": "^9.1.0", "usehooks-ts": "^2.10.0", - "viem": "~2.7.16", + "viem": "~2.7.20", "wagmi": "^2.5.7", "zod": "^3.22.4", "zod-fetch": "^0.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3bdb2e4..ba674c96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,14 +18,14 @@ dependencies: specifier: ^1.3.4 version: 1.3.4 '@mangrovedao/indexer-sdk': - specifier: 0.0.11-42 - version: 0.0.11-42(@graphql-tools/delegate@10.0.3)(@graphql-tools/utils@10.0.12)(@graphql-tools/wrap@10.0.1)(@types/node@18.19.10)(tslib@2.6.2)(typescript@5.3.3)(zod@3.22.4) + specifier: 0.0.11-44 + version: 0.0.11-44(@graphql-tools/delegate@10.0.3)(@graphql-tools/utils@10.0.12)(@graphql-tools/wrap@10.0.1)(@types/node@18.19.10)(tslib@2.6.2)(typescript@5.3.3)(zod@3.22.4) '@mangrovedao/mangrove-deployments': - specifier: 2.2.3 - version: 2.2.3 + specifier: 2.2.4-5 + version: 2.2.4-5 '@mangrovedao/mangrove.js': - specifier: 2.0.5-43 - version: 2.0.5-43 + specifier: 2.0.5-48 + version: 2.0.5-48 '@radix-ui/react-alert-dialog': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0) @@ -69,8 +69,8 @@ dependencies: specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0) '@rainbow-me/rainbowkit': - specifier: '2' - version: 2.0.1(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)(viem@2.7.16)(wagmi@2.5.7) + specifier: ^2.0.1 + version: 2.0.1(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)(viem@2.7.20)(wagmi@2.5.7) '@sentry/nextjs': specifier: ^7.93.0 version: 7.98.0(next@14.0.4)(react@18.2.0)(webpack@5.89.0) @@ -198,11 +198,11 @@ dependencies: specifier: ^2.10.0 version: 2.10.0(react-dom@18.2.0)(react@18.2.0) viem: - specifier: ~2.7.16 - version: 2.7.16(typescript@5.3.3)(zod@3.22.4) + specifier: ~2.7.20 + version: 2.7.20(typescript@5.3.3)(zod@3.22.4) wagmi: specifier: ^2.5.7 - version: 2.5.7(@tanstack/react-query@5.17.19)(@types/react@18.2.48)(react-dom@18.2.0)(react-native@0.73.4)(react@18.2.0)(typescript@5.3.3)(viem@2.7.16)(zod@3.22.4) + version: 2.5.7(@tanstack/react-query@5.17.19)(@types/react@18.2.48)(react-dom@18.2.0)(react-native@0.73.4)(react@18.2.0)(typescript@5.3.3)(viem@2.7.20)(zod@3.22.4) zod: specifier: ^3.22.4 version: 3.22.4 @@ -3317,8 +3317,8 @@ packages: semver: 7.5.4 dev: false - /@mangrovedao/indexer-sdk@0.0.11-42(@graphql-tools/delegate@10.0.3)(@graphql-tools/utils@10.0.12)(@graphql-tools/wrap@10.0.1)(@types/node@18.19.10)(tslib@2.6.2)(typescript@5.3.3)(zod@3.22.4): - resolution: {integrity: sha512-oEczGwr/DXWES+tIAUSyQ8ukHQ79ATkiVxr2HuATocHKOTG2azC6U1e0v0M4PHpBGiHOsMg07mgR/vOceJpzug==} + /@mangrovedao/indexer-sdk@0.0.11-44(@graphql-tools/delegate@10.0.3)(@graphql-tools/utils@10.0.12)(@graphql-tools/wrap@10.0.1)(@types/node@18.19.10)(tslib@2.6.2)(typescript@5.3.3)(zod@3.22.4): + resolution: {integrity: sha512-/5cTao96ORee5+brshm4CSZeKWlBq0t+dQgXP1hi6LITLeIDEdkKAcyeUmW09SUbSHp0aes74hkGRXEzMx6V2Q==} dependencies: '@ethersproject/bignumber': 5.7.0 '@graphprotocol/client-auto-pagination': 2.0.0(@graphql-tools/delegate@10.0.3)(@graphql-tools/utils@10.0.12)(@graphql-tools/wrap@10.0.1)(graphql@16.8.1) @@ -3332,7 +3332,7 @@ packages: '@graphql-mesh/utils': 0.94.6(@graphql-mesh/cross-helpers@0.4.1)(@graphql-tools/utils@10.0.12)(graphql@16.8.1)(tslib@2.6.2) big.js: 6.2.1 graphql: 16.8.1 - viem: 2.7.16(typescript@5.3.3)(zod@3.22.4) + viem: 2.7.20(typescript@5.3.3)(zod@3.22.4) transitivePeerDependencies: - '@graphql-mesh/types' - '@graphql-tools/delegate' @@ -3351,28 +3351,30 @@ packages: resolution: {integrity: sha512-AoM6PPQcByK58kVPoeNfnccfYnLat9duSI1o99eacfxhQP32B2pBCA7cIm/u7OgcYUg/ihQ+rTT27HVUO1WJgQ==} dev: false - /@mangrovedao/mangrove-deployments@2.2.3: - resolution: {integrity: sha512-QyBZ0jSDqEEZHWVO+20jm02zwIvBrFxuIiCD+AxFee3eDpndhY+Dgcythb6b5thuBJS7d1GolaD9E2D/+7mJHQ==} + /@mangrovedao/mangrove-deployments@2.2.4-5: + resolution: {integrity: sha512-L/KfJ6ZNPlOxlLMEWGQ7UUeQ8Kog6ZUadryGBQGv9h0i4QB8SBjVJ3U40lGFhRpTeEfor0nCy33zbiR81eeykg==} dependencies: '@mangrovedao/context-addresses': 1.3.4 semver: 7.5.4 dev: false - /@mangrovedao/mangrove-strats@2.1.0-4: - resolution: {integrity: sha512-aStAs8Qmdc9MC/ngkGD7rgvTAB1j0DZ0h4aDBEXEUC+mbBzeR5w7V+V1KceqLREx8pRlPbyQn4R9mQovQUJuug==} + /@mangrovedao/mangrove-strats@2.1.0-7: + resolution: {integrity: sha512-SLON4hC4zwhlpQhEvVPl4M2ZLekeUDTUWS8l0U6af82gwYd5YH9u6yZ8PP+W/hCsTRIMQlO9nGnybQxDrRAYsg==} dependencies: '@mangrovedao/mangrove-core': 2.1.1 + '@uniswap/v3-core': 1.0.1 + '@uniswap/v3-periphery': 1.4.4 dev: false - /@mangrovedao/mangrove.js@2.0.5-43: - resolution: {integrity: sha512-5G+itf9SZmMUj6Yhms3CUaJLaozFu0gmN+alwIMiOPbV45qLMecwdAly84wKbptl+hhipXFsyn6lUuD7lMhHsg==} + /@mangrovedao/mangrove.js@2.0.5-48: + resolution: {integrity: sha512-Kb6h18DMrMomnxqTvJ/oA4yJCaaQiEqpeMpMeyaY41+2/isA9q3DH6zBglZ5Iuw9ALdyQhTFX5jGYp4uU+pOlg==} hasBin: true dependencies: '@ethersproject/experimental': 5.7.0 '@mangrovedao/context-addresses': 1.3.4 '@mangrovedao/mangrove-core': 2.1.1 - '@mangrovedao/mangrove-deployments': 2.2.3 - '@mangrovedao/mangrove-strats': 2.1.0-4 + '@mangrovedao/mangrove-deployments': 2.2.4-5 + '@mangrovedao/mangrove-strats': 2.1.0-7 '@mangrovedao/reliable-event-subscriber': 1.1.30 '@types/object-inspect': 1.8.4 '@types/triple-beam': 1.3.5 @@ -3850,6 +3852,10 @@ packages: which: 4.0.0 dev: true + /@openzeppelin/contracts@3.4.2-solc-0.7: + resolution: {integrity: sha512-W6QmqgkADuFcTLzHL8vVoNBtkwjvQRpYIAom7KiUNoLKghyx3FgH0GBjt8NRvigV1ZmMOBllvE1By1C+bi8WpA==} + dev: false + /@parcel/watcher-android-arm64@2.3.0: resolution: {integrity: sha512-f4o9eA3dgk0XRT3XhB0UWpWpLnKgrh1IwNJKJ7UJek7eTYccQ8LR7XUWFKqw6aEq5KUNlCcGvSzKqSX/vtWVVA==} engines: {node: '>= 10.0.0'} @@ -5397,7 +5403,7 @@ packages: dependencies: '@babel/runtime': 7.23.6 - /@rainbow-me/rainbowkit@2.0.1(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)(viem@2.7.16)(wagmi@2.5.7): + /@rainbow-me/rainbowkit@2.0.1(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)(viem@2.7.20)(wagmi@2.5.7): resolution: {integrity: sha512-htE5nI0/2Q4UcLuiVW4IDmA6bqSyEzIB/XNcD1MEvYyLLZRTdQ8YGUhXAlZfYCC2Q+TcfYAxS826XcfPXwh7nQ==} engines: {node: '>=12.4'} peerDependencies: @@ -5415,8 +5421,8 @@ packages: react-dom: 18.2.0(react@18.2.0) react-remove-scroll: 2.5.7(@types/react@18.2.48)(react@18.2.0) ua-parser-js: 1.0.37 - viem: 2.7.16(typescript@5.3.3)(zod@3.22.4) - wagmi: 2.5.7(@tanstack/react-query@5.17.19)(@types/react@18.2.48)(react-dom@18.2.0)(react-native@0.73.4)(react@18.2.0)(typescript@5.3.3)(viem@2.7.16)(zod@3.22.4) + viem: 2.7.20(typescript@5.3.3)(zod@3.22.4) + wagmi: 2.5.7(@tanstack/react-query@5.17.19)(@types/react@18.2.48)(react-dom@18.2.0)(react-native@0.73.4)(react@18.2.0)(typescript@5.3.3)(viem@2.7.20)(zod@3.22.4) transitivePeerDependencies: - '@types/react' dev: false @@ -8064,6 +8070,32 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true + /@uniswap/lib@4.0.1-alpha: + resolution: {integrity: sha512-f6UIliwBbRsgVLxIaBANF6w09tYqc6Y/qXdsrbEmXHyFA7ILiKrIwRFXe1yOg8M3cksgVsO9N7yuL2DdCGQKBA==} + engines: {node: '>=10'} + dev: false + + /@uniswap/v2-core@1.0.1: + resolution: {integrity: sha512-MtybtkUPSyysqLY2U210NBDeCHX+ltHt3oADGdjqoThZaFRDKwM6k1Nb3F0A3hk5hwuQvytFWhrWHOEq6nVJ8Q==} + engines: {node: '>=10'} + dev: false + + /@uniswap/v3-core@1.0.1: + resolution: {integrity: sha512-7pVk4hEm00j9tc71Y9+ssYpO6ytkeI0y7WE9P6UcmNzhxPePwyAxImuhVsTqWK9YFvzgtvzJHi64pBl4jUzKMQ==} + engines: {node: '>=10'} + dev: false + + /@uniswap/v3-periphery@1.4.4: + resolution: {integrity: sha512-S4+m+wh8HbWSO3DKk4LwUCPZJTpCugIsHrWR86m/OrUyvSqGDTXKFfc2sMuGXCZrD1ZqO3rhQsKgdWg3Hbb2Kw==} + engines: {node: '>=10'} + dependencies: + '@openzeppelin/contracts': 3.4.2-solc-0.7 + '@uniswap/lib': 4.0.1-alpha + '@uniswap/v2-core': 1.0.1 + '@uniswap/v3-core': 1.0.1 + base64-sol: 1.0.1 + dev: false + /@use-gesture/core@10.3.0: resolution: {integrity: sha512-rh+6MND31zfHcy9VU3dOZCqGY511lvGcfyJenN4cWZe0u1BH6brBpBddLVXhF2r4BMqWbvxfsbL7D287thJU2A==} dev: false @@ -8404,7 +8436,7 @@ packages: react: 18.2.0 dev: false - /@wagmi/connectors@4.1.14(@types/react@18.2.48)(@wagmi/core@2.6.5)(react-dom@18.2.0)(react-native@0.73.4)(react@18.2.0)(typescript@5.3.3)(viem@2.7.16)(zod@3.22.4): + /@wagmi/connectors@4.1.14(@types/react@18.2.48)(@wagmi/core@2.6.5)(react-dom@18.2.0)(react-native@0.73.4)(react@18.2.0)(typescript@5.3.3)(viem@2.7.20)(zod@3.22.4): resolution: {integrity: sha512-e8I89FsNBtzhIilU3nqmgMR9xvSgCfmkWLz9iCKBTqyitbK5EJU7WTEtjjYFm1v2J//JeAwaA2XEKtG9BLR9jQ==} peerDependencies: '@wagmi/core': 2.6.5 @@ -8418,11 +8450,11 @@ packages: '@metamask/sdk': 0.14.3(@types/react@18.2.48)(react-dom@18.2.0)(react-native@0.73.4)(react@18.2.0) '@safe-global/safe-apps-provider': 0.18.1(typescript@5.3.3)(zod@3.22.4) '@safe-global/safe-apps-sdk': 8.1.0(typescript@5.3.3)(zod@3.22.4) - '@wagmi/core': 2.6.5(@types/react@18.2.48)(react@18.2.0)(typescript@5.3.3)(viem@2.7.16)(zod@3.22.4) + '@wagmi/core': 2.6.5(@types/react@18.2.48)(react@18.2.0)(typescript@5.3.3)(viem@2.7.20)(zod@3.22.4) '@walletconnect/ethereum-provider': 2.11.1(@types/react@18.2.48)(react@18.2.0) '@walletconnect/modal': 2.6.2(@types/react@18.2.48)(react@18.2.0) typescript: 5.3.3 - viem: 2.7.16(typescript@5.3.3)(zod@3.22.4) + viem: 2.7.20(typescript@5.3.3)(zod@3.22.4) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -8448,7 +8480,7 @@ packages: - zod dev: false - /@wagmi/core@2.6.5(@types/react@18.2.48)(react@18.2.0)(typescript@5.3.3)(viem@2.7.16)(zod@3.22.4): + /@wagmi/core@2.6.5(@types/react@18.2.48)(react@18.2.0)(typescript@5.3.3)(viem@2.7.20)(zod@3.22.4): resolution: {integrity: sha512-DLyrc0o+dx05oIhBJuxnS7ekS5e6rB5mytlqPme+Km7aLdeBdcfYB4yJyYCyWoi93OLa7M5sbflTttz3o56bKw==} peerDependencies: '@tanstack/query-core': '>=5.0.0' @@ -8463,7 +8495,7 @@ packages: eventemitter3: 5.0.1 mipd: 0.0.5(typescript@5.3.3)(zod@3.22.4) typescript: 5.3.3 - viem: 2.7.16(typescript@5.3.3)(zod@3.22.4) + viem: 2.7.20(typescript@5.3.3)(zod@3.22.4) zustand: 4.4.1(@types/react@18.2.48)(react@18.2.0) transitivePeerDependencies: - '@types/react' @@ -10017,6 +10049,10 @@ packages: /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + /base64-sol@1.0.1: + resolution: {integrity: sha512-ld3cCNMeXt4uJXmLZBHFGMvVpK9KsLVEhPpFRXnvSVAqABKbuNZg/+dsq3NuM+wxFLb/UrVkz7m1ciWmkMfTbg==} + dev: false + /bech32@1.1.4: resolution: {integrity: sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==} dev: false @@ -17888,8 +17924,8 @@ packages: - zod dev: false - /viem@2.7.16(typescript@5.3.3)(zod@3.22.4): - resolution: {integrity: sha512-yOPa9yaoJUm44m0Qe3ugHnkHol3QQlFxN3jT+bq+lQip7X7cWdPfmguyfLWX2viCXcmYZUDiQdeFbkPW9lw11Q==} + /viem@2.7.20(typescript@5.3.3)(zod@3.22.4): + resolution: {integrity: sha512-S31a24LWEjqXAjw1A+3/xALo+4eiYKklAjLtlLdPhA0cp+Kv6GcgruNVTktP8pKIGNYvpyQ+HA9PJyUhVXPdDw==} peerDependencies: typescript: '>=5.0.4' peerDependenciesMeta: @@ -17928,7 +17964,7 @@ packages: engines: {node: '>=0.10.0'} dev: false - /wagmi@2.5.7(@tanstack/react-query@5.17.19)(@types/react@18.2.48)(react-dom@18.2.0)(react-native@0.73.4)(react@18.2.0)(typescript@5.3.3)(viem@2.7.16)(zod@3.22.4): + /wagmi@2.5.7(@tanstack/react-query@5.17.19)(@types/react@18.2.48)(react-dom@18.2.0)(react-native@0.73.4)(react@18.2.0)(typescript@5.3.3)(viem@2.7.20)(zod@3.22.4): resolution: {integrity: sha512-xSuteMXFKvra4xDddqZbZv/gQlcg3X+To5AoZW7WoAm0iVlF8/vEGpQzCWy6KZs2z1szxPrr0YnH3Zr1Qj4E/A==} peerDependencies: '@tanstack/react-query': '>=5.0.0' @@ -17940,12 +17976,12 @@ packages: optional: true dependencies: '@tanstack/react-query': 5.17.19(react@18.2.0) - '@wagmi/connectors': 4.1.14(@types/react@18.2.48)(@wagmi/core@2.6.5)(react-dom@18.2.0)(react-native@0.73.4)(react@18.2.0)(typescript@5.3.3)(viem@2.7.16)(zod@3.22.4) - '@wagmi/core': 2.6.5(@types/react@18.2.48)(react@18.2.0)(typescript@5.3.3)(viem@2.7.16)(zod@3.22.4) + '@wagmi/connectors': 4.1.14(@types/react@18.2.48)(@wagmi/core@2.6.5)(react-dom@18.2.0)(react-native@0.73.4)(react@18.2.0)(typescript@5.3.3)(viem@2.7.20)(zod@3.22.4) + '@wagmi/core': 2.6.5(@types/react@18.2.48)(react@18.2.0)(typescript@5.3.3)(viem@2.7.20)(zod@3.22.4) react: 18.2.0 typescript: 5.3.3 use-sync-external-store: 1.2.0(react@18.2.0) - viem: 2.7.16(typescript@5.3.3)(zod@3.22.4) + viem: 2.7.20(typescript@5.3.3)(zod@3.22.4) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' diff --git a/providers/mangrove-indexer.tsx b/providers/mangrove-indexer.tsx index aa7b0d62..bbdb080a 100644 --- a/providers/mangrove-indexer.tsx +++ b/providers/mangrove-indexer.tsx @@ -9,6 +9,7 @@ import { useAccount } from "wagmi" import { getTokenPriceInUsd } from "@/services/tokens.service" import { TickPriceHelper } from "@mangrovedao/mangrove.js" import useMangrove from "./mangrove" +import Big from "big.js" const useIndexerSdkContext = () => { const { mangrove } = useMangrove() @@ -32,35 +33,33 @@ const useIndexerSdkContext = () => { return token.decimals }, createTickHelpers: async (ba, m) => { - const base = await mangrove?.tokenFromAddress(m.base.address) - const quote = await mangrove?.tokenFromAddress(m.quote.address) - if (!(mangrove && base && quote)) { - throw new Error("Impossible to determine market tokens") - } + const outbound = ba === "asks" ? m.base : m.quote + const inbound = ba === "asks" ? m.quote : m.base - const tickPriceHelper = new TickPriceHelper(ba, { - base, - quote, - tickSpacing: m.tickSpacing, - }) + const rawPriceFromTick = (tick: number) => 1.0001 ** tick return { priceFromTick(tick) { - return tickPriceHelper.priceFromTick(tick, "roundUp") + const rawPrice = rawPriceFromTick(tick) + const decimalsScaling = Big(10).pow( + m.base.decimals - m.quote.decimals, + ); + if (ba === "bids") { + return decimalsScaling.div(rawPrice) + } + return decimalsScaling.mul(rawPrice) }, inboundFromOutbound(tick, outboundAmount, roundUp) { - return tickPriceHelper.inboundFromOutbound( - tick, - outboundAmount, - roundUp ? "roundUp" : "nearest", - ) + const rawOutbound = Big(10).pow(outbound.decimals).mul(outboundAmount) + const price = rawPriceFromTick(tick) + const rawInbound = rawOutbound.mul(price).round(0, roundUp ? 3 : 0) + return rawInbound.div(Big(10).pow(inbound.decimals)) }, outboundFromInbound(tick, inboundAmount, roundUp) { - return tickPriceHelper.outboundFromInbound( - tick, - inboundAmount, - roundUp ? "roundUp" : "nearest", - ) + const rawInbound = Big(10).pow(inbound.decimals).mul(inboundAmount) + const price = rawPriceFromTick(tick) + const rawOutbound = rawInbound.div(price).round(0, roundUp ? 3 : 0) + return rawOutbound.div(Big(10).pow(outbound.decimals)) }, } }, @@ -73,7 +72,7 @@ const useIndexerSdkContext = () => { throw new Error( `Impossible to determine token from address: ${tokenAddress}`, ) - return getTokenPriceInUsd(token.symbol) + return getTokenPriceInUsd(token.symbol === "USDB" ? "USDC" : token.symbol) }, staleTime: 10 * 60 * 1000, }) diff --git a/providers/wallet-connect.tsx b/providers/wallet-connect.tsx index 77e302fc..f82a7652 100644 --- a/providers/wallet-connect.tsx +++ b/providers/wallet-connect.tsx @@ -8,9 +8,8 @@ import { getDefaultConfig, } from "@rainbow-me/rainbowkit" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { blast, blastSepolia, polygonMumbai } from "viem/chains" import { WagmiProvider, http } from "wagmi" -import * as wagmiChains from "wagmi/chains" -import { defineChain } from "viem" import { env } from "@/env.mjs" import { getWhitelistedChainObjects } from "@/utils/chains" @@ -18,43 +17,17 @@ import { getWhitelistedChainObjects } from "@/utils/chains" const queryClient = new QueryClient() const projectId = env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID -export const blast = /*#__PURE__*/ defineChain({ - id: 81457, - name: 'Blast', - iconUrl: - "https://cdn.routescan.io/_next/image?url=https%3A%2F%2Fcms-cdn.avascan.com%2Fcms2%2Fblast.dead36673539.png&w=48&q=100", - nativeCurrency: { - decimals: 18, - name: 'Ether', - symbol: 'ETH', - }, - rpcUrls: { - default: { http: ['https://rpc.blast.io'] }, - }, - blockExplorers: { - default: { name: 'Blastscan', url: 'https://blastscan.io' }, - }, - contracts: { - multicall3: { - address: '0xcA11bde05977b3631167028862bE2a173976CA11', - blockCreated: 212929, - }, - }, - sourceId: 1 // mainnet - }) - const config = getDefaultConfig({ appName: "Mangrove dApp", projectId, - chains: [blast, ...getWhitelistedChainObjects()], + // @ts-ignore + chains: getWhitelistedChainObjects(), + ssr: true, transports: { - [wagmiChains.polygon.id]: http(), - [wagmiChains.polygonMumbai.id]: http(), - [wagmiChains.blastSepolia.id]: http(), - [wagmiChains.arbitrum.id]: http(), - [81457]: http(), + [polygonMumbai.id]: http(), + [blastSepolia.id]: http(), + [blast.id]: http(), }, - ssr: true, }) export function WalletConnectProvider({ children }: React.PropsWithChildren) { diff --git a/public/assets/liquiditySources/pac.svg b/public/assets/liquiditySources/pac.svg new file mode 100644 index 00000000..2420568e --- /dev/null +++ b/public/assets/liquiditySources/pac.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/utils/chains.ts b/utils/chains.ts index 8a7c5e56..7b9870e1 100644 --- a/utils/chains.ts +++ b/utils/chains.ts @@ -1,6 +1,6 @@ import { env } from "@/env.mjs" -import * as wagmiChains from "wagmi/chains" -import { type Chain } from "wagmi/chains" +import type { Chain } from "viem/chains" +import * as wagmiChains from "viem/chains" const WHITELISTED_CHAIN_IDS = env.NEXT_PUBLIC_WHITELISTED_CHAIN_IDS const DEFAULT_CHAIN_ID = "80001" @@ -43,9 +43,12 @@ function renameChainNames(chains: wagmiChains.Chain[]) { return { ...chain, name: "Mumbai" } } - if (chain.id === wagmiChains.blastSepolia.id) { + if ( + chain.id === wagmiChains.blastSepolia.id || + chain.id === wagmiChains.blast.id + ) { return { - ...wagmiChains.blastSepolia, + ...chain, iconUrl: "https://cdn.routescan.io/_next/image?url=https%3A%2F%2Fcms-cdn.avascan.com%2Fcms2%2Fblast.dead36673539.png&w=48&q=100", }