diff --git a/apps/staking/package.json b/apps/staking/package.json index 3f55914fed..b4406a1e79 100644 --- a/apps/staking/package.json +++ b/apps/staking/package.json @@ -11,7 +11,7 @@ "fix": "pnpm fix:lint && pnpm fix:format", "fix:format": "prettier --write .", "fix:lint": "eslint --fix .", - "pull:env": "VERCEL_ORG_ID=team_BKQrg3JJFLxZyTqpuYtIY0rj VERCEL_PROJECT_ID= vercel env pull", + "pull:env": "VERCEL_ORG_ID=team_BKQrg3JJFLxZyTqpuYtIY0rj VERCEL_PROJECT_ID=prj_4aG4ZJ4S8b59nQID6LZDDb7c9cVY vercel env pull", "start:dev": "next dev", "start:prod": "next start", "test": "tsc && jest", @@ -37,6 +37,8 @@ "pino": "^9.3.2", "react": "^18.3.1", "react-dom": "^18.3.1", + "recharts": "^2.12.7", + "swr": "^2.2.5", "zod": "^3.23.8" }, "devDependencies": { diff --git a/apps/staking/src/api.ts b/apps/staking/src/api.ts index 034b88b4cb..d7b56094ba 100644 --- a/apps/staking/src/api.ts +++ b/apps/staking/src/api.ts @@ -1,58 +1,37 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ +// TODO remove these disables when moving off the mock APIs +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion */ import type { WalletContextState } from "@solana/wallet-adapter-react"; import type { Connection } from "@solana/web3.js"; -const MOCK_DELAY = 500; +export type StakeAccount = { + publicKey: `0x${string}`; +}; -const MOCK_DATA: Data = { - total: 15_000_000n, - availableRewards: 156_000n, - locked: 3_000_000n, - walletAmount: 200_000_000n, - governance: { - warmup: 2_670_000n, - staked: 4_150_000n, - cooldown: 1_850_000n, - cooldown2: 4_765_000n, - }, - integrityStakingPublishers: [ - { - name: "Foo Bar", - publicKey: "0xF00", - selfStake: 5_000_000_000n, - poolCapacity: 500_000_000n, - poolUtilization: 200_000_000n, - apy: 20, - numFeeds: 42, - qualityRanking: 1, - positions: { - warmup: 5_000_000n, - staked: 4_000_000n, - cooldown: 1_000_000n, - cooldown2: 460_000n, - }, - }, - { - name: "Jump Trading", - publicKey: "0xBA4", - selfStake: 400_000_000n, - poolCapacity: 500_000_000n, - poolUtilization: 600_000_000n, - apy: 10, - numFeeds: 84, - qualityRanking: 2, - positions: { - staked: 1_000_000n, - }, - }, - ], +export type Context = { + connection: Connection; + wallet: WalletContextState; + stakeAccount: StakeAccount; }; type Data = { total: bigint; availableRewards: bigint; + lastSlash: + | { + amount: bigint; + date: Date; + } + | undefined; + expiringRewards: { + amount: bigint; + expiry: Date; + }; locked: bigint; + unlockSchedule: { + date: Date; + amount: bigint; + }[]; walletAmount: bigint; governance: { warmup: bigint; @@ -62,13 +41,14 @@ type Data = { }; integrityStakingPublishers: { name: string; - publicKey: string; + publicKey: `0x${string}`; + isSelf: boolean; selfStake: bigint; poolCapacity: bigint; poolUtilization: bigint; - apy: number; numFeeds: number; qualityRanking: number; + apyHistory: { date: Date; apy: number }[]; positions?: | { warmup?: bigint | undefined; @@ -80,80 +60,168 @@ type Data = { }[]; }; -export const loadData = async ( +export enum StakeType { + Governance, + IntegrityStaking, +} + +const StakeDetails = { + Governance: () => ({ type: StakeType.Governance as const }), + IntegrityStaking: (publisherName: string) => ({ + type: StakeType.IntegrityStaking as const, + publisherName, + }), +}; + +export type StakeDetails = ReturnType< + (typeof StakeDetails)[keyof typeof StakeDetails] +>; + +export enum AccountHistoryItemType { + Deposit, + LockedDeposit, + Withdrawal, + RewardsCredited, + Claim, + Slash, + Unlock, + StakeCreated, + StakeFinishedWarmup, + UnstakeCreated, + UnstakeExitedCooldown, +} + +const AccountHistoryAction = { + Deposit: () => ({ type: AccountHistoryItemType.Deposit as const }), + LockedDeposit: (unlockDate: Date) => ({ + type: AccountHistoryItemType.LockedDeposit as const, + unlockDate, + }), + Withdrawal: () => ({ type: AccountHistoryItemType.Withdrawal as const }), + RewardsCredited: () => ({ + type: AccountHistoryItemType.RewardsCredited as const, + }), + Claim: () => ({ type: AccountHistoryItemType.Claim as const }), + Slash: (publisherName: string) => ({ + type: AccountHistoryItemType.Slash as const, + publisherName, + }), + Unlock: () => ({ type: AccountHistoryItemType.Unlock as const }), + StakeCreated: (details: StakeDetails) => ({ + type: AccountHistoryItemType.StakeCreated as const, + details, + }), + StakeFinishedWarmup: (details: StakeDetails) => ({ + type: AccountHistoryItemType.StakeFinishedWarmup as const, + details, + }), + UnstakeCreated: (details: StakeDetails) => ({ + type: AccountHistoryItemType.UnstakeCreated as const, + details, + }), + UnstakeExitedCooldown: (details: StakeDetails) => ({ + type: AccountHistoryItemType.UnstakeExitedCooldown as const, + details, + }), +}; + +export type AccountHistoryAction = ReturnType< + (typeof AccountHistoryAction)[keyof typeof AccountHistoryAction] +>; + +type AccountHistory = { + timestamp: Date; + action: AccountHistoryAction; + amount: bigint; + accountTotal: bigint; + availableToWithdraw: bigint; + availableRewards: bigint; + locked: bigint; +}[]; + +export const getStakeAccounts = async ( _connection: Connection, _wallet: WalletContextState, - _signal?: AbortSignal | undefined, -): Promise => { +): Promise => { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); + return MOCK_STAKE_ACCOUNTS; +}; + +export const loadData = async (context: Context): Promise => { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); + // While using mocks we need to clone the MOCK_DATA object every time + // `loadData` is called so that swr treats the response as changed and + // triggers a rerender. + return { ...MOCK_DATA[context.stakeAccount.publicKey]! }; +}; + +export const loadAccountHistory = async ( + context: Context, +): Promise => { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); - return MOCK_DATA; + return [...MOCK_HISTORY[context.stakeAccount.publicKey]!]; }; export const deposit = async ( - _connection: Connection, - _wallet: WalletContextState, + context: Context, amount: bigint, ): Promise => { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); - MOCK_DATA.total += amount; - MOCK_DATA.walletAmount -= amount; + MOCK_DATA[context.stakeAccount.publicKey]!.total += amount; + MOCK_DATA[context.stakeAccount.publicKey]!.walletAmount -= amount; }; export const withdraw = async ( - _connection: Connection, - _wallet: WalletContextState, + context: Context, amount: bigint, ): Promise => { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); - MOCK_DATA.total -= amount; - MOCK_DATA.walletAmount += amount; + MOCK_DATA[context.stakeAccount.publicKey]!.total -= amount; + MOCK_DATA[context.stakeAccount.publicKey]!.walletAmount += amount; }; -export const claim = async ( - _connection: Connection, - _wallet: WalletContextState, -): Promise => { +export const claim = async (context: Context): Promise => { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); - MOCK_DATA.total += MOCK_DATA.availableRewards; - MOCK_DATA.availableRewards = 0n; + MOCK_DATA[context.stakeAccount.publicKey]!.total += + MOCK_DATA[context.stakeAccount.publicKey]!.availableRewards; + MOCK_DATA[context.stakeAccount.publicKey]!.availableRewards = 0n; + MOCK_DATA[context.stakeAccount.publicKey]!.expiringRewards.amount = 0n; }; export const stakeGovernance = async ( - _connection: Connection, - _wallet: WalletContextState, + context: Context, amount: bigint, ): Promise => { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); - MOCK_DATA.governance.warmup += amount; + MOCK_DATA[context.stakeAccount.publicKey]!.governance.warmup += amount; }; export const cancelWarmupGovernance = async ( - _connection: Connection, - _wallet: WalletContextState, + context: Context, amount: bigint, ): Promise => { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); - MOCK_DATA.governance.warmup -= amount; + MOCK_DATA[context.stakeAccount.publicKey]!.governance.warmup -= amount; }; export const unstakeGovernance = async ( - _connection: Connection, - _wallet: WalletContextState, + context: Context, amount: bigint, ): Promise => { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); - MOCK_DATA.governance.staked -= amount; - MOCK_DATA.governance.cooldown += amount; + MOCK_DATA[context.stakeAccount.publicKey]!.governance.staked -= amount; + MOCK_DATA[context.stakeAccount.publicKey]!.governance.cooldown += amount; }; export const delegateIntegrityStaking = async ( - _connection: Connection, - _wallet: WalletContextState, + context: Context, publisherKey: string, amount: bigint, ): Promise => { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); - const publisher = MOCK_DATA.integrityStakingPublishers.find( + const publisher = MOCK_DATA[ + context.stakeAccount.publicKey + ]!.integrityStakingPublishers.find( (publisher) => publisher.publicKey === publisherKey, ); if (publisher) { @@ -165,13 +233,14 @@ export const delegateIntegrityStaking = async ( }; export const cancelWarmupIntegrityStaking = async ( - _connection: Connection, - _wallet: WalletContextState, + context: Context, publisherKey: string, amount: bigint, ): Promise => { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); - const publisher = MOCK_DATA.integrityStakingPublishers.find( + const publisher = MOCK_DATA[ + context.stakeAccount.publicKey + ]!.integrityStakingPublishers.find( (publisher) => publisher.publicKey === publisherKey, ); if (publisher) { @@ -184,13 +253,14 @@ export const cancelWarmupIntegrityStaking = async ( }; export const unstakeIntegrityStaking = async ( - _connection: Connection, - _wallet: WalletContextState, + context: Context, publisherKey: string, amount: bigint, ): Promise => { await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); - const publisher = MOCK_DATA.integrityStakingPublishers.find( + const publisher = MOCK_DATA[ + context.stakeAccount.publicKey + ]!.integrityStakingPublishers.find( (publisher) => publisher.publicKey === publisherKey, ); if (publisher) { @@ -203,3 +273,185 @@ export const unstakeIntegrityStaking = async ( throw new Error(`Invalid publisher key: "${publisherKey}"`); } }; + +export const calculateApy = ( + poolCapacity: bigint, + poolUtilization: bigint, + isSelf: boolean, +) => { + const maxApy = isSelf ? 25 : 20; + const minApy = isSelf ? 10 : 5; + return Math.min( + Math.max( + maxApy - Number((poolUtilization - poolCapacity) / 100_000_000n), + minApy, + ), + maxApy, + ); +}; + +const MOCK_DELAY = 500; + +const MOCK_STAKE_ACCOUNTS: StakeAccount[] = [ + { publicKey: "0x000000" }, + { publicKey: "0x111111" }, +]; + +const mkMockData = (isDouro: boolean): Data => ({ + total: 15_000_000n, + availableRewards: 156_000n, + lastSlash: isDouro + ? undefined + : { + amount: 2147n, + date: new Date("2024-05-04T00:00:00Z"), + }, + expiringRewards: { + amount: 56_000n, + expiry: new Date("2025-08-01T00:00:00Z"), + }, + locked: isDouro ? 3_000_000n : 0n, + unlockSchedule: isDouro + ? [ + { + amount: 1_000_000n, + date: new Date("2025-08-01T00:00:00Z"), + }, + { + amount: 2_000_000n, + date: new Date("2025-09-01T00:00:00Z"), + }, + ] + : [], + walletAmount: 5_000_000_000_000n, + governance: { + warmup: 2_670_000n, + staked: 4_150_000n, + cooldown: 1_850_000n, + cooldown2: 4_765_000n, + }, + integrityStakingPublishers: [ + { + name: "Douro Labs", + publicKey: "0xF00", + isSelf: isDouro, + selfStake: 5_000_000_000n, + poolCapacity: 500_000_000n, + poolUtilization: 200_000_000n, + numFeeds: 42, + qualityRanking: 1, + apyHistory: [ + { date: new Date("2024-07-22"), apy: 5 }, + { date: new Date("2024-07-23"), apy: 10 }, + { date: new Date("2024-07-24"), apy: 25 }, + { date: new Date("2024-07-25"), apy: 20 }, + ], + positions: { + warmup: 5_000_000n, + staked: 4_000_000n, + cooldown: 1_000_000n, + cooldown2: 460_000n, + }, + }, + { + name: "Jump Trading", + publicKey: "0xBA4", + isSelf: false, + selfStake: 400_000_000n, + poolCapacity: 500_000_000n, + poolUtilization: 750_000_000n, + numFeeds: 84, + qualityRanking: 2, + apyHistory: [ + { date: new Date("2024-07-24"), apy: 5 }, + { date: new Date("2024-07-25"), apy: 10 }, + ], + positions: { + staked: 1_000_000n, + }, + }, + { + name: "Cboe", + publicKey: "0xAA", + isSelf: false, + selfStake: 200_000_000n, + poolCapacity: 600_000_000n, + poolUtilization: 450_000_000n, + numFeeds: 17, + qualityRanking: 5, + apyHistory: [ + { date: new Date("2024-07-24"), apy: 5 }, + { date: new Date("2024-07-25"), apy: 10 }, + ], + }, + { + name: "Raydium", + publicKey: "0x111", + isSelf: false, + selfStake: 400_000_000n, + poolCapacity: 500_000_000n, + poolUtilization: 750_000_000n, + numFeeds: 84, + qualityRanking: 3, + apyHistory: [ + { date: new Date("2024-07-24"), apy: 5 }, + { date: new Date("2024-07-25"), apy: 10 }, + ], + }, + ], +}); + +const MOCK_DATA: Record< + (typeof MOCK_STAKE_ACCOUNTS)[number]["publicKey"], + Data +> = { + "0x000000": mkMockData(true), + "0x111111": mkMockData(false), +}; + +const mkMockHistory = (): AccountHistory => [ + { + timestamp: new Date("2024-06-10T00:00:00Z"), + action: AccountHistoryAction.Deposit(), + amount: 2_000_000n, + accountTotal: 2_000_000n, + availableRewards: 0n, + availableToWithdraw: 2_000_000n, + locked: 0n, + }, + { + timestamp: new Date("2024-06-14T02:00:00Z"), + action: AccountHistoryAction.RewardsCredited(), + amount: 200n, + accountTotal: 2_000_000n, + availableRewards: 200n, + availableToWithdraw: 2_000_000n, + locked: 0n, + }, + { + timestamp: new Date("2024-06-16T08:00:00Z"), + action: AccountHistoryAction.Claim(), + amount: 200n, + accountTotal: 2_000_200n, + availableRewards: 0n, + availableToWithdraw: 2_000_200n, + locked: 0n, + }, + { + timestamp: new Date("2024-06-16T08:00:00Z"), + action: AccountHistoryAction.Slash("Cboe"), + amount: 1000n, + accountTotal: 1_999_200n, + availableRewards: 0n, + availableToWithdraw: 1_999_200n, + locked: 0n, + }, +]; + +const MOCK_HISTORY: Record< + (typeof MOCK_STAKE_ACCOUNTS)[number]["publicKey"], + AccountHistory +> = { + "0x000000": mkMockHistory(), + "0x111111": mkMockHistory(), +}; diff --git a/apps/staking/src/components/AccountHistoryButton/index.tsx b/apps/staking/src/components/AccountHistoryButton/index.tsx new file mode 100644 index 0000000000..c391e5068f --- /dev/null +++ b/apps/staking/src/components/AccountHistoryButton/index.tsx @@ -0,0 +1,185 @@ +import useSWR from "swr"; + +import { + type AccountHistoryAction, + type StakeDetails, + AccountHistoryItemType, + StakeType, + loadAccountHistory, +} from "../../api"; +import { useApiContext } from "../../use-api-context"; +import { LoadingSpinner } from "../LoadingSpinner"; +import { ModalButton } from "../ModalButton"; +import { Tokens } from "../Tokens"; + +const ONE_SECOND_IN_MS = 1000; +const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS; +const REFRESH_INTERVAL = 1 * ONE_MINUTE_IN_MS; + +export const AccountHistoryButton = () => ( + + + +); + +const ModalBody = () => { + const history = useAccountHistoryData(); + + switch (history.type) { + case DataStateType.NotLoaded: + case DataStateType.Loading: { + return ; + } + case DataStateType.Error: { + return

Uh oh, an error occured!

; + } + case DataStateType.Loaded: { + return ( + + + + + + + + + + + + + + {history.data.map( + ( + { + accountTotal, + action, + amount, + availableRewards, + availableToWithdraw, + locked, + timestamp, + }, + i, + ) => ( + + + + + + + + + + ), + )} + +
TimestampDescriptionAmountAccount TotalAvailable RewardsAvailable to WithdrawLocked
{timestamp.toLocaleString()}{mkDescription(action)} + {amount} + + {accountTotal} + + {availableRewards} + + {availableToWithdraw} + + {locked} +
+ ); + } + } +}; + +const mkDescription = (action: AccountHistoryAction): string => { + switch (action.type) { + case AccountHistoryItemType.Claim: { + return "Rewards claimed"; + } + case AccountHistoryItemType.Deposit: { + return "Tokens deposited"; + } + case AccountHistoryItemType.LockedDeposit: { + return `Locked tokens deposited, unlocking ${action.unlockDate.toLocaleString()}`; + } + case AccountHistoryItemType.RewardsCredited: { + return "Rewards credited"; + } + case AccountHistoryItemType.Slash: { + return `Staked tokens slashed from ${action.publisherName}`; + } + case AccountHistoryItemType.StakeCreated: { + return `Created stake position for ${getStakeDetails(action.details)}`; + } + case AccountHistoryItemType.StakeFinishedWarmup: { + return `Warmup complete for position for ${getStakeDetails(action.details)}`; + } + case AccountHistoryItemType.Unlock: { + return "Locked tokens unlocked"; + } + case AccountHistoryItemType.UnstakeCreated: { + return `Requested unstake for position for ${getStakeDetails(action.details)}`; + } + case AccountHistoryItemType.UnstakeExitedCooldown: { + return `Cooldown completed for ${getStakeDetails(action.details)}`; + } + case AccountHistoryItemType.Withdrawal: { + return "Tokens withdrawn to wallet"; + } + } +}; + +const getStakeDetails = (details: StakeDetails): string => { + switch (details.type) { + case StakeType.Governance: { + return "Governance Staking"; + } + case StakeType.IntegrityStaking: { + return `Integrity Staking, publisher: ${details.publisherName}`; + } + } +}; + +const useAccountHistoryData = () => { + const apiContext = useApiContext(); + + const { data, isLoading, ...rest } = useSWR( + `${apiContext.stakeAccount.publicKey}/history`, + () => loadAccountHistory(apiContext), + { + refreshInterval: REFRESH_INTERVAL, + }, + ); + const error = rest.error as unknown; + + if (error) { + return DataState.ErrorState(error); + } else if (isLoading) { + return DataState.Loading(); + } else if (data) { + return DataState.Loaded(data); + } else { + return DataState.NotLoaded(); + } +}; + +enum DataStateType { + NotLoaded, + Loading, + Loaded, + Error, +} +const DataState = { + NotLoaded: () => ({ type: DataStateType.NotLoaded as const }), + Loading: () => ({ type: DataStateType.Loading as const }), + Loaded: (data: Awaited>) => ({ + type: DataStateType.Loaded as const, + data, + }), + ErrorState: (error: unknown) => ({ + type: DataStateType.Error as const, + error, + }), +}; +type DataState = ReturnType<(typeof DataState)[keyof typeof DataState]>; diff --git a/apps/staking/src/components/Dashboard/index.tsx b/apps/staking/src/components/Dashboard/index.tsx index a093f301d1..908c581902 100644 --- a/apps/staking/src/components/Dashboard/index.tsx +++ b/apps/staking/src/components/Dashboard/index.tsx @@ -1,67 +1,162 @@ "use client"; -import { ArrowPathIcon } from "@heroicons/react/24/outline"; -import { useWallet, useConnection } from "@solana/wallet-adapter-react"; -import { type ComponentProps, useCallback, useEffect, useState } from "react"; +import { + Listbox, + ListboxButton, + ListboxOptions, + ListboxOption, + Field, + Label, +} from "@headlessui/react"; +import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import useSWR from "swr"; import { DashboardLoaded } from "./loaded"; -import { loadData } from "../../api"; +import { WalletButton } from "./wallet-button"; +import { type StakeAccount, loadData } from "../../api"; +import { useApiContext } from "../../use-api-context"; +import { + StateType, + StakeAccountProvider, + useStakeAccount, +} from "../../use-stake-account"; +import { AccountHistoryButton } from "../AccountHistoryButton"; +import { LoadingSpinner } from "../LoadingSpinner"; -export const Dashboard = () => { - const { data, replaceData } = useDashboardData(); +const ONE_SECOND_IN_MS = 1000; +const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS; +const REFRESH_INTERVAL = 1 * ONE_MINUTE_IN_MS; + +export const Dashboard = () => ( + + + + +); + +const DashboardHeader = () => { + const stakeAccountState = useStakeAccount(); + return ( +
+ {stakeAccountState.type === StateType.Loaded && + stakeAccountState.allAccounts.length > 1 && ( + + )} + {stakeAccountState.type === StateType.Loaded && } + +
+ ); +}; + +type AccountSelectorProps = { + selectedAccount: StakeAccount; + accounts: [StakeAccount, ...StakeAccount[]]; + setAccount: (account: StakeAccount) => void; +}; + +const AccountSelector = ({ + accounts, + selectedAccount, + setAccount, +}: AccountSelectorProps) => ( + + + + +
{selectedAccount.publicKey}
+ +
+ + {accounts.map((account) => ( + +
{account.publicKey}
+
+ ))} +
+
+
+); + +const DashboardBody = () => { + const stakeAccountState = useStakeAccount(); + + switch (stakeAccountState.type) { + case StateType.Initialized: + case StateType.Loading: { + return ; + } + case StateType.NoAccounts: { + return

No stake account found for your wallet!

; + } + case StateType.Error: { + return ( +

+ Uh oh, an error occurred while loading stake accounts. Please refresh + and try again +

+ ); + } + case StateType.Loaded: { + return ; + } + } +}; + +const DashboardContents = () => { + const data = useDashboardData(); switch (data.type) { case DataStateType.NotLoaded: case DataStateType.Loading: { - return ; + return ; } case DataStateType.Error: { return

Uh oh, an error occured!

; } case DataStateType.Loaded: { - return ; + return ; } } }; -type DashboardData = Omit< - ComponentProps, - "replaceData" ->; - const useDashboardData = () => { - const [data, setData] = useState(DataState.NotLoaded()); - const wallet = useWallet(); - const { connection } = useConnection(); + const apiContext = useApiContext(); - const replaceData = useCallback( - (newData: DashboardData) => { - setData(DataState.Loaded(newData)); + const { data, isLoading, ...rest } = useSWR( + apiContext.stakeAccount.publicKey, + () => loadData(apiContext), + { + refreshInterval: REFRESH_INTERVAL, }, - [setData], ); + const error = rest.error as unknown; - useEffect(() => { - if (data.type === DataStateType.NotLoaded) { - setData(DataState.Loading()); - const abortController = new AbortController(); - loadData(connection, wallet, abortController.signal) - .then((data) => { - setData(DataState.Loaded(data)); - }) - .catch((error: unknown) => { - setData(DataState.ErrorState(error)); - }); - return () => { - abortController.abort(); - }; - } else { - return; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return { data, replaceData }; + if (error) { + return DataState.ErrorState(error); + } else if (isLoading) { + return DataState.Loading(); + } else if (data) { + return DataState.Loaded(data); + } else { + return DataState.NotLoaded(); + } }; enum DataStateType { @@ -73,7 +168,7 @@ enum DataStateType { const DataState = { NotLoaded: () => ({ type: DataStateType.NotLoaded as const }), Loading: () => ({ type: DataStateType.Loading as const }), - Loaded: (data: DashboardData) => ({ + Loaded: (data: Awaited>) => ({ type: DataStateType.Loaded as const, data, }), diff --git a/apps/staking/src/components/Dashboard/loaded.tsx b/apps/staking/src/components/Dashboard/loaded.tsx index 3d29782976..f2585657ca 100644 --- a/apps/staking/src/components/Dashboard/loaded.tsx +++ b/apps/staking/src/components/Dashboard/loaded.tsx @@ -1,8 +1,7 @@ -import type { WalletContextState } from "@solana/wallet-adapter-react"; -import type { Connection } from "@solana/web3.js"; import clsx from "clsx"; import { type ReactNode, useMemo, useCallback } from "react"; +import { SparkChart } from "./spark-chart"; import { deposit, withdraw, @@ -13,38 +12,53 @@ import { cancelWarmupIntegrityStaking, unstakeIntegrityStaking, claim, + calculateApy, } from "../../api"; +import type { Context } from "../../use-api-context"; import { StateType, useTransfer } from "../../use-transfer"; import { Button } from "../Button"; +import { ModalButton } from "../ModalButton"; import { Tokens } from "../Tokens"; import { TransferButton } from "../TransferButton"; type Props = { - replaceData: (newData: Omit) => void; total: bigint; + lastSlash: + | { + amount: bigint; + date: Date; + } + | undefined; walletAmount: bigint; availableRewards: bigint; + expiringRewards: { + amount: bigint; + expiry: Date; + }; locked: bigint; + unlockSchedule: { + amount: bigint; + date: Date; + }[]; governance: { warmup: bigint; staked: bigint; cooldown: bigint; cooldown2: bigint; }; - integrityStakingPublishers: Omit< - PublisherProps, - "availableToStake" | "replaceData" - >[]; + integrityStakingPublishers: PublisherProps["publisher"][]; }; export const DashboardLoaded = ({ total, + lastSlash, walletAmount, availableRewards, + expiringRewards, governance, integrityStakingPublishers, locked, - replaceData, + unlockSchedule, }: Props) => { const availableToStakeGovernance = useMemo( () => @@ -102,6 +116,16 @@ export const DashboardLoaded = ({ [availableToStakeGovernance, availableToStakeIntegrity], ); + const self = useMemo( + () => integrityStakingPublishers.find((publisher) => publisher.isSelf), + [integrityStakingPublishers], + ); + + const otherPublishers = useMemo( + () => integrityStakingPublishers.filter((publisher) => !publisher.isSelf), + [integrityStakingPublishers], + ); + return ( <>
@@ -113,12 +137,19 @@ export const DashboardLoaded = ({ actionDescription="Add funds to your balance" actionName="Deposit" max={walletAmount} - replaceData={replaceData} transfer={deposit} > In wallet: {walletAmount} } + {...(lastSlash && { + disclaimer: ( + <> + {lastSlash.amount} were slashed on{" "} + {lastSlash.date.toLocaleString()} + + ), + })} > {total} @@ -126,31 +157,82 @@ export const DashboardLoaded = ({ name="Available to withdraw" description="The lesser of the amount you have available to stake in governance & integrity staking" {...(availableToWithdraw > 0 && { - actions: ( - - Available to withdraw:{" "} - {availableToWithdraw} - - ), + actions: + availableRewards > 0 ? ( + + ) : ( + + Available to withdraw:{" "} + {availableToWithdraw} + + ), })} > {availableToWithdraw} 0n && { + disclaimer: ( + <> + {expiringRewards.amount} will expire on{" "} + {expiringRewards.expiry.toLocaleString()} if you have not + claimed before then + + ), + })} {...(availableRewards > 0 && { - actions: , + actions: , })} > {availableRewards} + {locked && ( + + + + + + + + + + {unlockSchedule.map((unlock, i) => ( + + + + + ))} + +
DateAmount
+ {unlock.date.toLocaleString()} + + {unlock.amount} +
+ + } + > + {locked} +
+ )}
@@ -165,7 +247,6 @@ export const DashboardLoaded = ({ actionDescription="Stake funds to participate in governance votes" actionName="Stake" max={availableToStakeGovernance} - replaceData={replaceData} transfer={stakeGovernance} > Available to stake:{" "} @@ -185,7 +266,6 @@ export const DashboardLoaded = ({ submitButtonText="Cancel Warmup" title="Cancel Governance Staking" max={governance.warmup} - replaceData={replaceData} transfer={cancelWarmupGovernance} > Max: {governance.warmup} @@ -206,7 +286,6 @@ export const DashboardLoaded = ({ actionName="Unstake" title="Unstake From Governance" max={governance.staked} - replaceData={replaceData} transfer={unstakeGovernance} > Max: {governance.staked} @@ -271,24 +350,53 @@ export const DashboardLoaded = ({ {integrityStakingCooldown2}
+ {self && ( +
+ + + + + + + + + + + + + +
+ You ({self.name}) +
PoolHistorical APYNumber of feedsQuality ranking
+
+ )} - + + - {integrityStakingPublishers.map((publisher) => ( + {otherPublishers.map((publisher) => ( ))} @@ -315,8 +423,9 @@ const useIntegrityStakingSum = ( type BalanceCategoryProps = { children: bigint; - name: string; - description?: string | undefined; + name: ReactNode | ReactNode[]; + description?: ReactNode | ReactNode[] | undefined; + disclaimer?: ReactNode | ReactNode[] | undefined; actions?: ReactNode | ReactNode[]; }; @@ -324,6 +433,7 @@ const BalanceCategory = ({ children, name, description, + disclaimer, actions, }: BalanceCategoryProps) => (
@@ -331,10 +441,15 @@ const BalanceCategory = ({
{children}
-
{name}
+
{name}
{description && (

{description}

)} + {disclaimer && ( +

+ {disclaimer} +

+ )}
{actions &&
{actions}
} @@ -375,63 +490,60 @@ const Position = ({ ); type PublisherProps = { + availableRewards: bigint; availableToStake: bigint; - replaceData: Props["replaceData"]; - name: string; - publicKey: string; - selfStake: bigint; - poolCapacity: bigint; - poolUtilization: bigint; - apy: number; - numFeeds: number; - qualityRanking: number; - positions?: - | { - warmup?: bigint | undefined; - staked?: bigint | undefined; - cooldown?: bigint | undefined; - cooldown2?: bigint | undefined; - } - | undefined; + omitName?: boolean; + omitSelfStake?: boolean; + publisher: { + name: string; + publicKey: string; + isSelf: boolean; + selfStake: bigint; + poolCapacity: bigint; + poolUtilization: bigint; + numFeeds: number; + qualityRanking: number; + apyHistory: { date: Date; apy: number }[]; + positions?: + | { + warmup?: bigint | undefined; + staked?: bigint | undefined; + cooldown?: bigint | undefined; + cooldown2?: bigint | undefined; + } + | undefined; + }; }; const Publisher = ({ - name, - publicKey, - selfStake, - poolUtilization, - poolCapacity, - apy, - numFeeds, - qualityRanking, - positions, + availableRewards, + publisher, availableToStake, - replaceData, + omitName, + omitSelfStake, }: PublisherProps) => { - const delegate = useTransferActionForPublisher( - delegateIntegrityStaking, - publicKey, - ); const cancelWarmup = useTransferActionForPublisher( cancelWarmupIntegrityStaking, - publicKey, + publisher.publicKey, ); const unstake = useTransferActionForPublisher( unstakeIntegrityStaking, - publicKey, + publisher.publicKey, ); const utilizationPercent = useMemo( - () => Number((100n * poolUtilization) / poolCapacity), - [poolUtilization, poolCapacity], + () => Number((100n * publisher.poolUtilization) / publisher.poolCapacity), + [publisher.poolUtilization, publisher.poolCapacity], ); return ( <> - - + {!omitName && } + {!omitSelfStake && ( + + )} + - - + + {availableToStake > 0 && ( )} - {positions && ( - + {publisher.positions && ( +
Publishers + {self ? "Other Publishers" : "Publishers"} +
Publisher Self stake PoolHistorical APY Number of feeds Quality ranking
{name} - {selfStake} - {publisher.name} + {publisher.selfStake} +
poolCapacity + publisher.poolUtilization > publisher.poolCapacity ? "bg-red-500" : "bg-pythpurple-400", )} @@ -448,45 +560,63 @@ const Publisher = ({
poolCapacity }, + { + "text-white": + publisher.poolUtilization > publisher.poolCapacity, + }, )} > - {poolUtilization} + {publisher.poolUtilization} / - {poolCapacity} + {publisher.poolCapacity} ({utilizationPercent.toFixed(2)}%)
APY:
-
{apy}%
+
+ {calculateApy( + publisher.poolCapacity, + publisher.poolUtilization, + publisher.isSelf, + )} + % +
+
+
+
+ ({ + date, + value: apy, + }))} + />
{numFeeds}{qualityRanking}{publisher.numFeeds}{publisher.qualityRanking} - - Available to stake:{" "} - {availableToStake} - +
-
+
Your Positions @@ -502,44 +632,50 @@ const Publisher = ({ name="Warmup" actions={ Max:{" "} - {positions.warmup ?? 0n} + {publisher.positions.warmup ?? 0n} } > - {positions.warmup} + {publisher.positions.warmup} - Max:{" "} - {positions.staked ?? 0n} - + availableRewards > 0 ? ( + + ) : ( + + Max:{" "} + {publisher.positions.staked ?? 0n} + + ) } > - {positions.staked} + {publisher.positions.staked} - {positions.cooldown} + {publisher.positions.cooldown} - {positions.cooldown2} + {publisher.positions.cooldown2}
@@ -551,21 +687,6 @@ const Publisher = ({ ); }; -const useTransferActionForPublisher = ( - action: ( - connection: Connection, - wallet: WalletContextState, - publicKey: string, - amount: bigint, - ) => Promise, - publicKey: string, -) => - useCallback( - (connection: Connection, wallet: WalletContextState, amount: bigint) => - action(connection, wallet, publicKey, amount), - [action, publicKey], - ); - type PublisherPositionProps = { name: string; children: bigint | undefined; @@ -591,23 +712,112 @@ const PublisherPosition = ({ // eslint-disable-next-line unicorn/no-array-reduce const bigIntMin = (...args: bigint[]) => args.reduce((m, e) => (e < m ? e : m)); -type ClaimButtonProps = { - replaceData: Props["replaceData"]; -}; - -const ClaimButton = ({ replaceData }: ClaimButtonProps) => { - const { state, execute } = useTransfer(claim, replaceData); +const ClaimButton = () => { + const { state, execute } = useTransfer(claim); return ( ); }; + +type ClaimRequiredButtonProps = { + buttonText: string; + description: string; + availableRewards: bigint; +}; + +const ClaimRequiredButton = ({ + buttonText, + description, + availableRewards, +}: ClaimRequiredButtonProps) => { + const { state, execute } = useTransfer(claim); + + const isSubmitting = state.type === StateType.Submitting; + + return ( + ( + + )} + description={description} + > +
+ Available Rewards: {availableRewards} +
+
+ ); +}; + +type StakeToPublisherButtonProps = { + publisherName: string; + publisherKey: string; + availableToStake: bigint; + poolCapacity: bigint; + poolUtilization: bigint; + isSelf: boolean; +}; + +const StakeToPublisherButton = ({ + publisherName, + publisherKey, + poolCapacity, + poolUtilization, + availableToStake, + isSelf, +}: StakeToPublisherButtonProps) => { + const delegate = useTransferActionForPublisher( + delegateIntegrityStaking, + publisherKey, + ); + + return ( + + {(amount) => ( + <> + Available to stake:{" "} + {availableToStake} + {amount !== undefined && ( +
+ Staking these tokens will change the APY to:{" "} + {calculateApy(poolCapacity, poolUtilization + amount, isSelf)}% +
+ )} + + )} +
+ ); +}; + +const useTransferActionForPublisher = ( + action: ( + context: Context, + publicKey: string, + amount: bigint, + ) => Promise, + publicKey: string, +) => + useCallback( + (context: Context, amount: bigint) => action(context, publicKey, amount), + [action, publicKey], + ); diff --git a/apps/staking/src/components/Dashboard/spark-chart.tsx b/apps/staking/src/components/Dashboard/spark-chart.tsx new file mode 100644 index 0000000000..973457a579 --- /dev/null +++ b/apps/staking/src/components/Dashboard/spark-chart.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useCallback } from "react"; +import { ResponsiveContainer, LineChart, Tooltip, Line, XAxis } from "recharts"; + +type Props = { + data: { date: Date; value: number }[]; +}; + +export const SparkChart = ({ data }: Props) => { + const formatDate = useCallback((date: Date) => date.toLocaleDateString(), []); + + return ( + + + } + allowEscapeViewBox={{ x: true, y: true }} + /> + + + + + ); +}; + +type TooltipProps = { + formatDate: (date: Date) => string; + label?: Date; + payload?: { + value?: number; + }[]; +}; + +const TooltipContent = ({ payload, label, formatDate }: TooltipProps) => ( +
+ {label ? formatDate(label) : ""} + {payload?.[0]?.value ?? 0} +
+); diff --git a/apps/staking/src/components/Home/wallet-button.tsx b/apps/staking/src/components/Dashboard/wallet-button.tsx similarity index 96% rename from apps/staking/src/components/Home/wallet-button.tsx rename to apps/staking/src/components/Dashboard/wallet-button.tsx index 8c1437fdc8..26ab7a08ae 100644 --- a/apps/staking/src/components/Home/wallet-button.tsx +++ b/apps/staking/src/components/Dashboard/wallet-button.tsx @@ -24,7 +24,7 @@ export const WalletButton = ( /* no-op, no worries if we can't show a SNS domain */ }); } - }, [wallet, connection]); + }, [wallet.publicKey, connection]); return {primaryDomain}; }; diff --git a/apps/staking/src/components/Home/index.tsx b/apps/staking/src/components/Home/index.tsx index 52533f84b2..a7dd345f0d 100644 --- a/apps/staking/src/components/Home/index.tsx +++ b/apps/staking/src/components/Home/index.tsx @@ -1,14 +1,13 @@ "use client"; -import { ArrowPathIcon } from "@heroicons/react/24/outline"; import { useWallet } from "@solana/wallet-adapter-react"; import { useWalletModal } from "@solana/wallet-adapter-react-ui"; import { useCallback } from "react"; -import { WalletButton } from "./wallet-button"; import { useIsMounted } from "../../use-is-mounted"; import { Button } from "../Button"; import { Dashboard } from "../Dashboard"; +import { LoadingSpinner } from "../LoadingSpinner"; export const Home = () => (
@@ -26,12 +25,10 @@ const HomeContents = () => { const showModal = useCallback(() => { modal.setVisible(true); }, [modal]); + if (isMounted) { return wallet.connected ? ( - <> - - - + ) : ( <>

@@ -47,6 +44,6 @@ const HomeContents = () => { ); } else { - return ; + return ; } }; diff --git a/apps/staking/src/components/LoadingSpinner/index.tsx b/apps/staking/src/components/LoadingSpinner/index.tsx new file mode 100644 index 0000000000..1fa9786369 --- /dev/null +++ b/apps/staking/src/components/LoadingSpinner/index.tsx @@ -0,0 +1,5 @@ +import { ArrowPathIcon } from "@heroicons/react/24/outline"; + +export const LoadingSpinner = () => ( + +); diff --git a/apps/staking/src/components/Modal/index.tsx b/apps/staking/src/components/Modal/index.tsx index 46950ad1b8..73c76340da 100644 --- a/apps/staking/src/components/Modal/index.tsx +++ b/apps/staking/src/components/Modal/index.tsx @@ -5,9 +5,10 @@ import { Description, DialogPanel, CloseButton, + Transition, } from "@headlessui/react"; import { XMarkIcon } from "@heroicons/react/24/outline"; -import type { ReactNode } from "react"; +import { type ReactNode, useCallback } from "react"; import { Button } from "../Button"; @@ -17,7 +18,7 @@ type Props = { closeDisabled?: boolean | undefined; afterLeave?: (() => void) | undefined; children?: ReactNode | ReactNode[] | undefined; - title: string; + title: ReactNode | ReactNode[]; description?: string; additionalButtons?: ReactNode | ReactNode[] | undefined; }; @@ -26,49 +27,61 @@ export const Modal = ({ open, onClose, closeDisabled, + afterLeave, children, title, description, additionalButtons, -}: Props) => ( -

- -
- { + const handleClose = useCallback(() => { + if (!closeDisabled) { + onClose(); + } + }, [closeDisabled, onClose]); + + return ( + + - +
+ - {title} - - {closeDisabled !== true && ( - - - - )} - {description && ( - - {description} - - )} - {children} -
- - Close - - {additionalButtons} -
- -
-
-); + {title} + + {closeDisabled !== true && ( + + + + )} + {description && ( + + {description} + + )} + {children} +
+ + Close + + {additionalButtons} +
+ +
+
+ ); +}; diff --git a/apps/staking/src/components/ModalButton/index.tsx b/apps/staking/src/components/ModalButton/index.tsx new file mode 100644 index 0000000000..e204007ba7 --- /dev/null +++ b/apps/staking/src/components/ModalButton/index.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { + type ComponentProps, + type ReactNode, + useCallback, + useState, +} from "react"; + +import { Button } from "../Button"; +import { Modal } from "../Modal"; + +type Props = Omit< + ComponentProps, + "open" | "onClose" | "additionalButtons" +> & { + buttonContent?: ReactNode | ReactNode[] | undefined; + onClose?: () => void; + additionalButtons?: + | ((onClose: () => void) => ReactNode | ReactNode[]) + | ReactNode + | ReactNode[] + | undefined; +}; + +export const ModalButton = ({ + buttonContent, + title, + onClose, + additionalButtons, + ...props +}: Props) => { + const [isOpen, setIsOpen] = useState(false); + const close = useCallback(() => { + if (onClose) { + onClose(); + } + setIsOpen(false); + }, [setIsOpen, onClose]); + const open = useCallback(() => { + setIsOpen(true); + }, [setIsOpen]); + + return ( + <> + + + + ); +}; diff --git a/apps/staking/src/components/Root/wallet-provider.tsx b/apps/staking/src/components/Root/wallet-provider.tsx index e21487e151..e1dacb231e 100644 --- a/apps/staking/src/components/Root/wallet-provider.tsx +++ b/apps/staking/src/components/Root/wallet-provider.tsx @@ -9,7 +9,6 @@ import { WalletModalProvider } from "@solana/wallet-adapter-react-ui"; import { GlowWalletAdapter, LedgerWalletAdapter, - PhantomWalletAdapter, SolflareWalletAdapter, SolletExtensionWalletAdapter, SolletWalletAdapter, @@ -41,7 +40,6 @@ export const WalletProvider = ({ () => [ new GlowWalletAdapter(), new LedgerWalletAdapter(), - new PhantomWalletAdapter(), new SolflareWalletAdapter(), new SolletExtensionWalletAdapter(), new SolletWalletAdapter(), diff --git a/apps/staking/src/components/Tokens/index.tsx b/apps/staking/src/components/Tokens/index.tsx index a19a47a0e1..5b89f08e3c 100644 --- a/apps/staking/src/components/Tokens/index.tsx +++ b/apps/staking/src/components/Tokens/index.tsx @@ -1,17 +1,21 @@ -import { useMemo } from "react"; +import clsx from "clsx"; +import { useMemo, type HTMLAttributes } from "react"; import Pyth from "./pyth.svg"; import { tokensToString } from "../../tokens"; -type Props = { +type Props = Omit, "children"> & { children: bigint; }; -export const Tokens = ({ children }: Props) => { +export const Tokens = ({ children, className, ...props }: Props) => { const value = useMemo(() => tokensToString(children), [children]); return ( - + {value} diff --git a/apps/staking/src/components/TransferButton/index.tsx b/apps/staking/src/components/TransferButton/index.tsx index 5ab7a392a1..2717dff691 100644 --- a/apps/staking/src/components/TransferButton/index.tsx +++ b/apps/staking/src/components/TransferButton/index.tsx @@ -1,5 +1,3 @@ -import { type WalletContextState } from "@solana/wallet-adapter-react"; -import type { Connection } from "@solana/web3.js"; import { type ChangeEvent, type ComponentProps, @@ -9,11 +7,12 @@ import { useState, } from "react"; +import { useLogger } from "../../logger"; import { stringToTokens } from "../../tokens"; +import { type Context } from "../../use-api-context"; import { StateType, useTransfer } from "../../use-transfer"; import { Button } from "../Button"; -import type { DashboardLoaded } from "../Dashboard/loaded"; -import { Modal } from "../Modal"; +import { ModalButton } from "../ModalButton"; type Props = { actionName: string; @@ -21,13 +20,12 @@ type Props = { title?: string | undefined; submitButtonText?: string | undefined; max: bigint; - replaceData: ComponentProps["replaceData"]; - children?: ReactNode | ReactNode[] | undefined; - transfer: ( - connection: Connection, - wallet: WalletContextState, - amount: bigint, - ) => Promise; + children?: + | ((amount: bigint | undefined) => ReactNode | ReactNode[]) + | ReactNode + | ReactNode[] + | undefined; + transfer: (context: Context, amount: bigint) => Promise; }; export const TransferButton = ({ @@ -36,88 +34,99 @@ export const TransferButton = ({ actionDescription, title, max, - replaceData, transfer, children, }: Props) => { - const [isOpen, setIsOpen] = useState(false); - const [amountInput, setAmountInput] = useState(""); - - const updateAmount = useCallback( - (event: ChangeEvent) => { - setAmountInput(event.target.value); - }, - [setAmountInput], - ); - - const amount = useMemo(() => { - const amount = stringToTokens(amountInput); - return amount !== undefined && amount <= max && amount > 0n - ? amount - : undefined; - }, [amountInput, max]); - + const { amountInput, updateAmount, resetAmount, amount } = + useAmountInput(max); const doTransfer = useCallback( - (connection: Connection, wallet: WalletContextState) => + (context: Context) => amount === undefined ? Promise.reject(new InvalidAmountError()) - : transfer(connection, wallet, amount), + : transfer(context, amount), [amount, transfer], ); - const close = useCallback(() => { - setAmountInput(""); - setIsOpen(false); - }, [setAmountInput, setIsOpen]); - - const { state, execute } = useTransfer(doTransfer, replaceData, (reset) => { - close(); - reset(); - }); + const { state, execute } = useTransfer(doTransfer); + const isSubmitting = state.type === StateType.Submitting; - const isLoading = useMemo( - () => - state.type === StateType.Submitting || - state.type === StateType.LoadingData, - [state], + return ( + ( + + {submitButtonText ?? actionName} + + )} + > + + {children && ( +
+ {typeof children === "function" ? children(amount) : children} +
+ )} + {state.type === StateType.Error &&

Uh oh, an error occurred!

} +
); +}; - const open = useCallback(() => { - setIsOpen(true); - }, [setIsOpen]); +const useAmountInput = (max: bigint) => { + const [amountInput, setAmountInput] = useState(""); - const closeUnlessLoading = useCallback(() => { - if (!isLoading) { - close(); - } - }, [isLoading, close]); + return { + amountInput, - return ( - <> - - - {submitButtonText ?? actionName} - - } - > - - {children &&
{children}
} -
- - ); + updateAmount: useCallback( + (event: ChangeEvent) => { + setAmountInput(event.target.value); + }, + [setAmountInput], + ), + resetAmount: useCallback(() => { + setAmountInput(""); + }, [setAmountInput]), + + amount: useMemo(() => { + const amountAsTokens = stringToTokens(amountInput); + return amountAsTokens !== undefined && + amountAsTokens <= max && + amountAsTokens > 0n + ? amountAsTokens + : undefined; + }, [amountInput, max]), + }; }; class InvalidAmountError extends Error { - override message = "Invalid amount"; + constructor() { + super("Invalid amount"); + } } + +type ExecuteButtonProps = Omit, "onClick"> & { + execute: () => Promise; + close: () => void; +}; + +const ExecuteButton = ({ execute, close, ...props }: ExecuteButtonProps) => { + const logger = useLogger(); + const handleClick = useCallback(async () => { + try { + await execute(); + close(); + } catch (error: unknown) { + logger.error(error); + } + }, [execute, close, logger]); + + return