From 4e1328079621135b695b49fbda2687d22715a721 Mon Sep 17 00:00:00 2001 From: JJ Adonis Date: Mon, 19 Dec 2022 16:33:20 +0800 Subject: [PATCH] feature(walletkit-ui): userPreferences, loans and DeFiScanContext (#45) * feature(walletkit-ui): userPreferences, loans and DeFiScanContext * feature(walletkit-ui): userPreferences, loans and DeFiScanContext --- .../src/contexts/DeFiScanContext.tsx | 98 +++ packages/walletkit-ui/src/contexts/index.ts | 1 + packages/walletkit-ui/src/hooks/index.ts | 2 + .../src/hooks/useCollateralizationRatio.tsx | 197 ++++++ .../walletkit-ui/src/hooks/useVaultStatus.tsx | 43 ++ packages/walletkit-ui/src/index.ts | 1 + packages/walletkit-ui/src/store/index.ts | 3 + packages/walletkit-ui/src/store/loans.ts | 232 +++++++ packages/walletkit-ui/src/store/loans.unit.ts | 623 ++++++++++++++++++ .../src/store/types/VaultStatus.ts | 33 + .../walletkit-ui/src/store/types/index.ts | 1 + .../walletkit-ui/src/store/userPreferences.ts | 130 ++++ 12 files changed, 1364 insertions(+) create mode 100644 packages/walletkit-ui/src/contexts/DeFiScanContext.tsx create mode 100644 packages/walletkit-ui/src/hooks/index.ts create mode 100644 packages/walletkit-ui/src/hooks/useCollateralizationRatio.tsx create mode 100644 packages/walletkit-ui/src/hooks/useVaultStatus.tsx create mode 100644 packages/walletkit-ui/src/store/loans.ts create mode 100644 packages/walletkit-ui/src/store/loans.unit.ts create mode 100644 packages/walletkit-ui/src/store/types/VaultStatus.ts create mode 100644 packages/walletkit-ui/src/store/types/index.ts create mode 100644 packages/walletkit-ui/src/store/userPreferences.ts diff --git a/packages/walletkit-ui/src/contexts/DeFiScanContext.tsx b/packages/walletkit-ui/src/contexts/DeFiScanContext.tsx new file mode 100644 index 0000000..85aafda --- /dev/null +++ b/packages/walletkit-ui/src/contexts/DeFiScanContext.tsx @@ -0,0 +1,98 @@ +import { EnvironmentNetwork } from "@waveshq/walletkit-core"; +import React, { createContext, useContext, useMemo } from "react"; + +import { useNetworkContext } from "./NetworkContext"; + +interface DeFiScanContextI { + getTransactionUrl: (txid: string, rawtx?: string) => string; + getBlocksUrl: (blockCount: number) => string; + getTokenUrl: (tokenId: number | string) => string; + getAddressUrl: (address: string) => string; + getVaultsUrl: (vaultId: string) => string; + getAuctionsUrl: (vaultId: string, index: number) => string; + getBlocksCountdownUrl: (blockCount: number) => string; +} + +const DeFiScanContext = createContext(undefined as any); +const baseDefiScanUrl = "https://defiscan.live"; + +export function useDeFiScanContext(): DeFiScanContextI { + return useContext(DeFiScanContext); +} + +function getNetworkParams(network: EnvironmentNetwork): string { + switch (network) { + case EnvironmentNetwork.MainNet: + // no-op: network param not required for MainNet + return ""; + case EnvironmentNetwork.TestNet: + return `?network=${EnvironmentNetwork.TestNet}`; + + case EnvironmentNetwork.LocalPlayground: + case EnvironmentNetwork.RemotePlayground: + return `?network=${EnvironmentNetwork.RemotePlayground}`; + default: + return ""; + } +} + +export function getURLByNetwork( + path: string, + network: EnvironmentNetwork, + id: number | string +): string { + return `${baseDefiScanUrl}/${path}/${id}${getNetworkParams(network)}`; +} + +export function getTxURLByNetwork( + network: EnvironmentNetwork, + txid: string, + rawtx?: string +): string { + let baseUrl = `${baseDefiScanUrl}/transactions/${txid}`; + + baseUrl += getNetworkParams(network); + + if (typeof rawtx === "string" && rawtx.length !== 0) { + if (network === EnvironmentNetwork.MainNet) { + baseUrl += `?rawtx=${rawtx}`; + } else { + baseUrl += `&rawtx=${rawtx}`; + } + } + + return baseUrl; +} + +export function DeFiScanProvider( + props: React.PropsWithChildren +): JSX.Element | null { + const { network } = useNetworkContext(); + const { children } = props; + + const context: DeFiScanContextI = useMemo( + () => ({ + getTransactionUrl: (txid: string, rawtx?: string): string => + getTxURLByNetwork(network, txid, rawtx), + getBlocksUrl: (blockCount: number) => + getURLByNetwork("blocks", network, blockCount), + getTokenUrl: (tokenId: number | string) => + getURLByNetwork("tokens", network, tokenId), + getAddressUrl: (address: string) => + getURLByNetwork("address", network, address), + getVaultsUrl: (vaultId: string) => + getURLByNetwork("vaults", network, vaultId), + getAuctionsUrl: (vaultId: string, index: number) => + getURLByNetwork(`vaults/${vaultId}/auctions`, network, index), + getBlocksCountdownUrl: (blockCount: number) => + getURLByNetwork("blocks/countdown", network, blockCount), + }), + [network] + ); + + return ( + + {children} + + ); +} diff --git a/packages/walletkit-ui/src/contexts/index.ts b/packages/walletkit-ui/src/contexts/index.ts index 3651dab..0df8df2 100644 --- a/packages/walletkit-ui/src/contexts/index.ts +++ b/packages/walletkit-ui/src/contexts/index.ts @@ -1,3 +1,4 @@ +export * from "./DeFiScanContext"; export * from "./LanguageProvider"; export * from "./NetworkContext"; export * from "./PlaygroundContext"; diff --git a/packages/walletkit-ui/src/hooks/index.ts b/packages/walletkit-ui/src/hooks/index.ts new file mode 100644 index 0000000..21f6f43 --- /dev/null +++ b/packages/walletkit-ui/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./useCollateralizationRatio"; +export * from "./useVaultStatus"; diff --git a/packages/walletkit-ui/src/hooks/useCollateralizationRatio.tsx b/packages/walletkit-ui/src/hooks/useCollateralizationRatio.tsx new file mode 100644 index 0000000..0677168 --- /dev/null +++ b/packages/walletkit-ui/src/hooks/useCollateralizationRatio.tsx @@ -0,0 +1,197 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import BigNumber from "bignumber.js"; + +import { + CollateralizationRatioProps, + CollateralizationRatioStats, + VaultStatus, +} from "../store/types/VaultStatus"; + +export function useCollateralRatioStats({ + colRatio, + minColRatio, + totalLoanAmount, + totalCollateralValue, +}: CollateralizationRatioProps): CollateralizationRatioStats { + const atRiskThreshold = new BigNumber(minColRatio).multipliedBy(1.5); + const liquidatedThreshold = new BigNumber(minColRatio).multipliedBy(1.25); + const isInLiquidation = + totalLoanAmount.gt(0) && colRatio.isLessThan(liquidatedThreshold); + const isAtRisk = + totalLoanAmount.gt(0) && colRatio.isLessThan(atRiskThreshold); + return { + atRiskThreshold, + liquidatedThreshold, + isInLiquidation, + isAtRisk, + isHealthy: !isInLiquidation && !isAtRisk && totalLoanAmount.gt(0), + isReady: + !isInLiquidation && + !isAtRisk && + totalLoanAmount.eq(0) && + totalCollateralValue !== undefined && + totalCollateralValue.gt(0), + }; +} + +// export function useCollateralizationRatioColor( +// props: CollateralizationRatioProps +// ): ThemedProps { +// const style: ThemedProps = {}; +// const stats = useCollateralRatioStats(props); + +// if (stats.isInLiquidation) { +// style.light = tailwind("text-error-500"); +// style.dark = tailwind("text-darkerror-500"); +// } else if (stats.isAtRisk) { +// style.light = tailwind("text-warning-500"); +// style.dark = tailwind("text-darkwarning-500"); +// } else if (stats.isHealthy) { +// style.light = tailwind("text-success-500"); +// style.dark = tailwind("text-darksuccess-500"); +// } +// return style; +// } + +// export function getVaultStatusColor( +// status: string, +// isLight: boolean, +// isText: boolean = false +// ): string { +// if (status === VaultStatus.NearLiquidation) { +// return isText ? "text-red-v2" : getColor("red-v2"); +// } else if (status === VaultStatus.AtRisk) { +// return isText ? "text-orange-v2" : getColor("orange-v2"); +// } else if (status === VaultStatus.Healthy || status === VaultStatus.Ready) { +// return isText ? "text-green-v2" : getColor("green-v2"); +// } +// return isText +// ? isLight +// ? "text-mono-light-v2-500" +// : "text-mono-dark-v2-500" +// : getColor(isLight ? "mono-light-v2-300" : "mono-dark-v2-300"); +// } + +export function getVaultStatusText(status: string): string { + switch (status) { + case VaultStatus.Ready: + return "Ready"; + case VaultStatus.Halted: + return "Halted"; + default: + return "Empty"; + } +} + +export function useResultingCollateralizationRatioByCollateral({ + collateralValue, + collateralRatio, + minCollateralRatio, + totalLoanAmount, + numOfColorBars = 6, + totalCollateralValueInUSD, +}: { + collateralValue: string; + collateralRatio: BigNumber; + minCollateralRatio: BigNumber; + totalLoanAmount: BigNumber; + totalCollateralValue?: BigNumber; + numOfColorBars?: number; + totalCollateralValueInUSD: BigNumber; +}): { + resultingColRatio: BigNumber; + displayedColorBars: number; +} { + const hasCollateralRatio = + !new BigNumber(collateralRatio).isNaN() && + new BigNumber(collateralRatio).isPositive(); + const resultingColRatio = + collateralValue === "" || + !hasCollateralRatio || + new BigNumber(collateralValue).isZero() + ? new BigNumber(collateralRatio) + : totalCollateralValueInUSD.dividedBy(totalLoanAmount).multipliedBy(100); + + const numOfColorBarPerStatus = numOfColorBars / 3; // (3): liquidation, at risk, healthy + const healthyThresholdRatio = 1.75; + const atRiskThresholdRatio = 1.5; + const liquidatedThresholdRatio = 1.25; + const atRiskThreshold = new BigNumber(minCollateralRatio).multipliedBy( + atRiskThresholdRatio + ); + const liquidatedThreshold = new BigNumber(minCollateralRatio).multipliedBy( + liquidatedThresholdRatio + ); + + const isAtRisk = + totalLoanAmount.gt(0) && resultingColRatio.isLessThan(atRiskThreshold); + const isInLiquidation = + totalLoanAmount.gt(0) && resultingColRatio.isLessThan(liquidatedThreshold); + const isHealthy = !isInLiquidation && !isAtRisk && totalLoanAmount.gt(0); + + const getRatio = (): number => { + if (isHealthy) { + return healthyThresholdRatio; + } + if (isAtRisk && !isInLiquidation) { + return atRiskThresholdRatio; + } + return liquidatedThresholdRatio; + }; + + const getColorBarsCount = ( + numOfColorBarPerStatus: number, + minCollateralRatio: BigNumber, + resultingCollateralRatio: BigNumber, + thresholdRatio: number, + isHealthy: boolean + ): number => { + let colorBarsCount = -1; + let index = 1; + while (colorBarsCount === -1 && index <= numOfColorBarPerStatus) { + const colorBarMaxAmount = minCollateralRatio.plus( + minCollateralRatio.multipliedBy( + new BigNumber(thresholdRatio) + .minus(1) + .dividedBy(isHealthy ? 1 : numOfColorBarPerStatus) + .times(index) // divide threshold to number of bars + ) + ); + + if (resultingCollateralRatio.isLessThanOrEqualTo(colorBarMaxAmount)) { + colorBarsCount = index; + } + + index += 1; + } + + return colorBarsCount; + }; + + const colorBarsCount = getColorBarsCount( + numOfColorBarPerStatus, + minCollateralRatio, + resultingColRatio, + getRatio(), + isHealthy + ); + + let displayedColorBars = -1; + + if (resultingColRatio.isLessThanOrEqualTo(0)) { + displayedColorBars = -1; + } else if (isHealthy && colorBarsCount > 0) { + displayedColorBars = colorBarsCount + numOfColorBarPerStatus * 2; + } else if (isHealthy && colorBarsCount === -1) { + displayedColorBars = numOfColorBars; // display full color bar + } else if (isAtRisk && !isInLiquidation) { + displayedColorBars = colorBarsCount + numOfColorBarPerStatus; + } else { + displayedColorBars = colorBarsCount; + } + + return { + displayedColorBars, + resultingColRatio, + }; +} diff --git a/packages/walletkit-ui/src/hooks/useVaultStatus.tsx b/packages/walletkit-ui/src/hooks/useVaultStatus.tsx new file mode 100644 index 0000000..c66612d --- /dev/null +++ b/packages/walletkit-ui/src/hooks/useVaultStatus.tsx @@ -0,0 +1,43 @@ +import { LoanVaultState } from "@defichain/whale-api-client/dist/api/loan"; +import BigNumber from "bignumber.js"; + +import { VaultHealthItem, VaultStatus } from "../store/types/VaultStatus"; +import { useCollateralRatioStats } from "./useCollateralizationRatio"; + +export function useVaultStatus( + status: LoanVaultState | undefined, + collateralRatio: BigNumber, + minColRatio: BigNumber, + totalLoanAmount: BigNumber, + totalCollateralValue: BigNumber +): VaultHealthItem { + const colRatio = collateralRatio.gte(0) ? collateralRatio : new BigNumber(0); + const stats = useCollateralRatioStats({ + colRatio, + minColRatio, + totalLoanAmount, + totalCollateralValue, + }); + let vaultStatus: VaultStatus; + if (status === LoanVaultState.FROZEN) { + vaultStatus = VaultStatus.Halted; + } else if (status === LoanVaultState.UNKNOWN) { + vaultStatus = VaultStatus.Unknown; + } else if (status === LoanVaultState.IN_LIQUIDATION) { + vaultStatus = VaultStatus.Liquidated; + } else if (stats.isInLiquidation) { + vaultStatus = VaultStatus.NearLiquidation; + } else if (stats.isAtRisk) { + vaultStatus = VaultStatus.AtRisk; + } else if (stats.isHealthy) { + vaultStatus = VaultStatus.Healthy; + } else if (stats.isReady) { + vaultStatus = VaultStatus.Ready; + } else { + vaultStatus = VaultStatus.Empty; + } + return { + status: vaultStatus, + vaultStats: stats, + }; +} diff --git a/packages/walletkit-ui/src/index.ts b/packages/walletkit-ui/src/index.ts index 02821fe..6e3ded5 100644 --- a/packages/walletkit-ui/src/index.ts +++ b/packages/walletkit-ui/src/index.ts @@ -1,2 +1,3 @@ export * from "./contexts"; +export * from "./hooks"; export * from "./store"; diff --git a/packages/walletkit-ui/src/store/index.ts b/packages/walletkit-ui/src/store/index.ts index 1db5416..ade4a7f 100644 --- a/packages/walletkit-ui/src/store/index.ts +++ b/packages/walletkit-ui/src/store/index.ts @@ -1,5 +1,8 @@ export * from "./block"; +export * from "./loans"; export * from "./ocean"; export * from "./transaction_queue"; +export * from "./types"; +export * from "./userPreferences"; export * from "./wallet"; export * from "./website"; diff --git a/packages/walletkit-ui/src/store/loans.ts b/packages/walletkit-ui/src/store/loans.ts new file mode 100644 index 0000000..9e7d182 --- /dev/null +++ b/packages/walletkit-ui/src/store/loans.ts @@ -0,0 +1,232 @@ +import { WhaleApiClient } from "@defichain/whale-api-client"; +import { + CollateralToken, + LoanScheme, + LoanToken, + LoanVaultActive, + LoanVaultLiquidated, + LoanVaultState, +} from "@defichain/whale-api-client/dist/api/loan"; +import { ActivePrice } from "@defichain/whale-api-client/dist/api/prices"; +import { + createAsyncThunk, + createSelector, + createSlice, + PayloadAction, +} from "@reduxjs/toolkit"; +import BigNumber from "bignumber.js"; + +import { useVaultStatus } from "../hooks"; +import { VaultStatus } from "./types"; + +export type LoanVault = LoanVaultActive | LoanVaultLiquidated; + +export interface LoanPaymentTokenActivePrices { + [key: string]: ActivePrice; +} + +export interface LoansState { + vaults: LoanVault[]; + loanTokens: LoanToken[]; + loanSchemes: LoanScheme[]; + collateralTokens: CollateralToken[]; + loanPaymentTokenActivePrices: LoanPaymentTokenActivePrices; + hasFetchedVaultsData: boolean; + hasFetchedLoansData: boolean; + hasFetchedLoanSchemes: boolean; +} + +const initialState: LoansState = { + vaults: [], + loanTokens: [], + loanSchemes: [], + collateralTokens: [], + loanPaymentTokenActivePrices: {}, + hasFetchedVaultsData: false, + hasFetchedLoansData: false, + hasFetchedLoanSchemes: false, +}; + +// TODO (Harsh) Manage pagination for all api +export const fetchVaults = createAsyncThunk( + "wallet/fetchVaults", + async ({ + size = 200, + address, + client, + }: { + size?: number; + address: string; + client: WhaleApiClient; + }) => client.address.listVault(address, size) +); + +export const fetchLoanTokens = createAsyncThunk( + "wallet/fetchLoanTokens", + async ({ size = 200, client }: { size?: number; client: WhaleApiClient }) => + client.loan.listLoanToken(size) +); + +export const fetchLoanSchemes = createAsyncThunk( + "wallet/fetchLoanSchemes", + async ({ size = 50, client }: { size?: number; client: WhaleApiClient }) => + client.loan.listScheme(size) +); + +export const fetchCollateralTokens = createAsyncThunk( + "wallet/fetchCollateralTokens", + async ({ size = 50, client }: { size?: number; client: WhaleApiClient }) => + client.loan.listCollateralToken(size) +); + +export const fetchPrice = createAsyncThunk( + "wallet/fetchPrice", + async ({ + client, + token, + currency, + }: { + token: string; + currency: string; + client: WhaleApiClient; + }) => { + const activePrices = await client.prices.getFeedActive(token, currency, 1); + return activePrices[0]; + } +); + +export const loans = createSlice({ + name: "loans", + initialState, + reducers: { + setHasFetchedVaultsData: (state, action: PayloadAction) => { + state.hasFetchedVaultsData = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase( + fetchVaults.fulfilled, + (state, action: PayloadAction) => { + state.vaults = action.payload; + state.hasFetchedVaultsData = true; + } + ); + builder.addCase( + fetchLoanTokens.fulfilled, + (state, action: PayloadAction) => { + state.loanTokens = action.payload; + state.hasFetchedLoansData = true; + } + ); + builder.addCase( + fetchLoanSchemes.fulfilled, + (state, action: PayloadAction) => { + state.loanSchemes = action.payload; + state.hasFetchedLoanSchemes = true; + } + ); + builder.addCase( + fetchCollateralTokens.fulfilled, + (state, action: PayloadAction) => { + state.collateralTokens = action.payload; + } + ); + builder.addCase( + fetchPrice.fulfilled, + (state, action: PayloadAction) => { + state.loanPaymentTokenActivePrices = { + ...state.loanPaymentTokenActivePrices, + ...{ + [action.payload.key]: action.payload, + }, + }; + } + ); + }, +}); + +export const selectLoansState = (state: any): LoansState => state.loans; + +export const ascColRatioLoanScheme = createSelector( + (state: LoansState) => state.loanSchemes, + (schemes) => + schemes + .map((c) => c) + .sort((a, b) => + new BigNumber(a.minColRatio).minus(b.minColRatio).toNumber() + ) +); + +export const loanTokensSelector = createSelector( + (state: LoansState) => state.loanTokens, + (loanTokens) => loanTokens +); + +const selectTokenId = (state: LoansState, tokenId: string): string => tokenId; + +export const loanTokenByTokenId = createSelector( + [selectTokenId, loanTokensSelector], + (tokenId, loanTokens) => + loanTokens.find((loanToken) => loanToken.token.id === tokenId) +); + +export const loanPaymentTokenActivePrices = createSelector( + (state: LoansState) => state.loanPaymentTokenActivePrices, + (activePrices) => activePrices +); + +export const vaultsSelector = createSelector( + (state: LoansState) => state.vaults, + (vaults) => { + const order = { + [VaultStatus.NearLiquidation]: 1, + [VaultStatus.AtRisk]: 2, + [VaultStatus.Healthy]: 3, + [VaultStatus.Liquidated]: 4, + [VaultStatus.Ready]: 5, + [VaultStatus.Halted]: 6, + [VaultStatus.Empty]: 7, + [VaultStatus.Unknown]: 8, + }; + + return vaults + .map((vault) => { + if (vault.state === LoanVaultState.IN_LIQUIDATION) { + return { + ...vault, + vaultState: VaultStatus.Liquidated, + }; + } + + const colRatio = new BigNumber(vault.collateralRatio); + const minColRatio = new BigNumber(vault.loanScheme.minColRatio); + const totalLoanValue = new BigNumber(vault.loanValue); + const totalCollateralValue = new BigNumber(vault.collateralValue); + const vaultState = useVaultStatus( + vault.state, + colRatio, + minColRatio, + totalLoanValue, + totalCollateralValue + ); + return { + ...vault, + vaultState: vaultState.status, + }; + }) + .sort((a, b) => order[a.vaultState] - order[b.vaultState]); + } +); + +//* Filter vaults that will be removed with Total Portfolio Amount +export const activeVaultsSelector = createSelector( + vaultsSelector, + (vaults) => + vaults.filter((value: LoanVault) => + [ + LoanVaultState.ACTIVE, + LoanVaultState.MAY_LIQUIDATE, + LoanVaultState.FROZEN, + ].includes(value.state) + ) as LoanVaultActive[] +); diff --git a/packages/walletkit-ui/src/store/loans.unit.ts b/packages/walletkit-ui/src/store/loans.unit.ts new file mode 100644 index 0000000..23969f7 --- /dev/null +++ b/packages/walletkit-ui/src/store/loans.unit.ts @@ -0,0 +1,623 @@ +import { + CollateralToken, + LoanScheme, + LoanToken, + LoanVaultLiquidated, + LoanVaultState, +} from "@defichain/whale-api-client/dist/api/loan"; + +import { + ascColRatioLoanScheme, + fetchCollateralTokens, + fetchLoanSchemes, + fetchLoanTokens, + fetchVaults, + loans, + LoansState, + loanTokenByTokenId, + loanTokensSelector, + LoanVault, + vaultsSelector, +} from "./loans"; +import { VaultStatus } from "./types"; + +describe("loans reducer", () => { + let initialState: LoansState; + const vault: LoanVault = { + vaultId: "eee84f2cc56bbc51a42eaf302b76d4d1250b58b943829ee82f2fa9a46a9e4319", + loanScheme: { + id: "MIN150", + minColRatio: "150", + interestRate: "5", + }, + ownerAddress: "bcrt1q39r84tmh4xp7wmg32tnza8j544lynknvy8q2nr", + state: LoanVaultState.ACTIVE, + informativeRatio: "9999.94300032", + collateralRatio: "10000", + collateralValue: "100", + loanValue: "1.0000057", + interestValue: "0.0000057", + collateralAmounts: [ + { + id: "0", + amount: "1.00000000", + symbol: "DFI", + symbolKey: "DFI", + name: "Default Defi token", + displaySymbol: "DFI", + }, + ], + loanAmounts: [ + { + id: "14", + amount: "1.00000570", + symbol: "DUSD", + symbolKey: "DUSD", + name: "Decentralized USD", + displaySymbol: "DUSD", + }, + { + id: "13", + amount: "0.00000001", + symbol: "TD10", + symbolKey: "TD10", + name: "Decentralized TD10", + displaySymbol: "dTD10", + }, + ], + interestAmounts: [ + { + id: "14", + amount: "0.00000570", + symbol: "DUSD", + symbolKey: "DUSD", + name: "Decentralized USD", + displaySymbol: "DUSD", + }, + { + id: "13", + amount: "0.00000000", + symbol: "TD10", + symbolKey: "TD10", + name: "Decentralized TD10", + displaySymbol: "dTD10", + }, + ], + }; + const loanTokens: LoanToken[] = [ + { + tokenId: + "a3a124bf5a6c37fe8d293b45bf32a16e412d06c376d0a042078279564c07ac2e", + token: { + id: "10", + symbol: "TD10", + symbolKey: "TD10", + name: "Decentralized TD10", + decimal: 8, + limit: "0", + mintable: true, + tradeable: true, + isDAT: true, + isLPS: false, + finalized: false, + minted: "0.0001027", + creation: { + tx: "a3a124bf5a6c37fe8d293b45bf32a16e412d06c376d0a042078279564c07ac2e", + height: 128, + }, + destruction: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: -1, + }, + collateralAddress: "bcrt1qyrfrpadwgw7p5eh3e9h3jmu4kwlz4prx73cqny", + displaySymbol: "dTD10", + isLoanToken: true, + }, + interest: "1.5", + fixedIntervalPriceId: "TD10/USD", + }, + { + tokenId: + "b99d32217b8fbe8872015c1a376f745a94372f061a0fb88c9b212876e4f158f5", + token: { + id: "14", + symbol: "DUSD", + symbolKey: "DUSD", + name: "Decentralized USD", + decimal: 8, + limit: "0", + mintable: true, + tradeable: true, + isDAT: true, + isLPS: false, + finalized: false, + minted: "20540", + creation: { + tx: "b99d32217b8fbe8872015c1a376f745a94372f061a0fb88c9b212876e4f158f5", + height: 128, + }, + destruction: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: -1, + }, + collateralAddress: "bcrt1qyrfrpadwgw7p5eh3e9h3jmu4kwlz4prx73cqny", + displaySymbol: "DUSD", + isLoanToken: true, + }, + interest: "0", + fixedIntervalPriceId: "DUSD/USD", + }, + { + tokenId: + "ffbaea57f155a36700c65a018aafe5e7d9984416339fb623df887f1a82b12142", + token: { + id: "11", + symbol: "TR50", + symbolKey: "TR50", + name: "Decentralized TR50", + decimal: 8, + limit: "0", + mintable: true, + tradeable: true, + isDAT: true, + isLPS: false, + finalized: false, + minted: "10.27", + creation: { + tx: "ffbaea57f155a36700c65a018aafe5e7d9984416339fb623df887f1a82b12142", + height: 128, + }, + destruction: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: -1, + }, + collateralAddress: "bcrt1qyrfrpadwgw7p5eh3e9h3jmu4kwlz4prx73cqny", + displaySymbol: "dTR50", + isLoanToken: true, + }, + interest: "3", + fixedIntervalPriceId: "TR50/USD", + }, + ]; + const loanSchemes: LoanScheme[] = [ + { + id: "MIN10000", + minColRatio: "1000", + interestRate: "0.5", + }, + { + id: "MIN150", + minColRatio: "150", + interestRate: "5", + }, + { + id: "MIN175", + minColRatio: "175", + interestRate: "3", + }, + { + id: "MIN200", + minColRatio: "200", + interestRate: "2", + }, + { + id: "MIN350", + minColRatio: "350", + interestRate: "1.5", + }, + { + id: "MIN500", + minColRatio: "500", + interestRate: "1", + }, + ]; + + beforeEach(() => { + initialState = { + vaults: [], + loanTokens: [], + loanSchemes: [], + collateralTokens: [], + loanPaymentTokenActivePrices: {}, + hasFetchedLoanSchemes: false, + hasFetchedVaultsData: false, + hasFetchedLoansData: false, + }; + }); + + it("should handle initial state", () => { + expect(loans.reducer(undefined, { type: "unknown" })).toEqual({ + vaults: [], + loanTokens: [], + loanSchemes: [], + collateralTokens: [], + loanPaymentTokenActivePrices: {}, + hasFetchedVaultsData: false, + hasFetchedLoansData: false, + hasFetchedLoanSchemes: false, + }); + }); + + it("should handle fetch vaults", () => { + const action = { type: fetchVaults.fulfilled, payload: [vault] }; + const actual = loans.reducer(initialState, action); + expect(actual.vaults).toStrictEqual([vault]); + }); + + it("should handle fetch loan tokens", () => { + const action = { type: fetchLoanTokens.fulfilled, payload: loanTokens }; + const actual = loans.reducer(initialState, action); + expect(actual.loanTokens).toStrictEqual(loanTokens); + }); + + it("should handle fetch loan schemes", () => { + const action = { type: fetchLoanSchemes.fulfilled, payload: loanSchemes }; + const actual = loans.reducer(initialState, action); + expect(actual.loanSchemes).toStrictEqual(loanSchemes); + }); + + it("should handle fetch collateral tokens", () => { + const collateralTokens: CollateralToken[] = [ + { + tokenId: + "08987c2d1f3d7d5a18a331c4a173a85be34cf5d2438a3e51a2ed4ed2779a6279", + token: { + id: "8", + symbol: "CS25", + symbolKey: "CS25", + name: "Playground CS25", + decimal: 8, + limit: "0", + mintable: true, + tradeable: true, + isDAT: true, + isLPS: false, + finalized: false, + minted: "100000000", + creation: { + tx: "37e2279b80e68f55fe1ccf9920a084731cd08e331a5ee6f7769759263e66bdcb", + height: 118, + }, + destruction: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: -1, + }, + collateralAddress: "mswsMVsyGMj1FzDMbbxw2QW3KvQAv2FKiy", + displaySymbol: "dCS25", + isLoanToken: false, + }, + factor: "1", + activateAfterBlock: 130, + fixedIntervalPriceId: "CS25/USD", + }, + { + tokenId: + "0b990af4ede825e3b626ac3eaa72111babf0ee5e188e66ce503415e0d3f88031", + token: { + id: "3", + symbol: "USDT", + symbolKey: "USDT", + name: "Playground USDT", + decimal: 8, + limit: "0", + mintable: true, + tradeable: true, + isDAT: true, + isLPS: false, + finalized: false, + minted: "1000000000", + creation: { + tx: "3fba5bf3426acbe9e3aadc9827ec8eb646ee6a2e6b09eb41ce69bddfe054d03a", + height: 107, + }, + destruction: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: -1, + }, + collateralAddress: "mswsMVsyGMj1FzDMbbxw2QW3KvQAv2FKiy", + displaySymbol: "dUSDT", + isLoanToken: false, + }, + factor: "1", + activateAfterBlock: 129, + fixedIntervalPriceId: "USDT/USD", + }, + { + tokenId: + "11c000d76c6d45f069630ffb3534d69f1b0e1d75a1f97d9bb3fcfaa051116126", + token: { + id: "9", + symbol: "CR50", + symbolKey: "CR50", + name: "Playground CR50", + decimal: 8, + limit: "0", + mintable: true, + tradeable: true, + isDAT: true, + isLPS: false, + finalized: false, + minted: "100000000", + creation: { + tx: "2f35eb08a993b052cbb60fb27062c6ff6f88015c92566a243d0092c267a31462", + height: 120, + }, + destruction: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: -1, + }, + collateralAddress: "mswsMVsyGMj1FzDMbbxw2QW3KvQAv2FKiy", + displaySymbol: "dCR50", + isLoanToken: false, + }, + factor: "1", + activateAfterBlock: 130, + fixedIntervalPriceId: "CR50/USD", + }, + ]; + const action = { + type: fetchCollateralTokens.fulfilled, + payload: collateralTokens, + }; + const actual = loans.reducer(initialState, action); + expect(actual.collateralTokens).toStrictEqual(collateralTokens); + }); + + it("should be able to select loan schemes with ascending collateralization ratio", () => { + const state = { + ...initialState, + loanSchemes, + }; + const actual = ascColRatioLoanScheme(state); + expect(actual).toStrictEqual([ + { + id: "MIN150", + minColRatio: "150", + interestRate: "5", + }, + { + id: "MIN175", + minColRatio: "175", + interestRate: "3", + }, + { + id: "MIN200", + minColRatio: "200", + interestRate: "2", + }, + { + id: "MIN350", + minColRatio: "350", + interestRate: "1.5", + }, + { + id: "MIN500", + minColRatio: "500", + interestRate: "1", + }, + { + id: "MIN10000", + minColRatio: "1000", + interestRate: "0.5", + }, + ]); + }); + + it("should be able to select loans token that returns DUSD with active price", () => { + const state = { + ...initialState, + loanTokens, + }; + const loanTokensWithDUSDActivePrice: LoanToken[] = [ + { + tokenId: + "a3a124bf5a6c37fe8d293b45bf32a16e412d06c376d0a042078279564c07ac2e", + token: { + id: "10", + symbol: "TD10", + symbolKey: "TD10", + name: "Decentralized TD10", + decimal: 8, + limit: "0", + mintable: true, + tradeable: true, + isDAT: true, + isLPS: false, + finalized: false, + minted: "0.0001027", + creation: { + tx: "a3a124bf5a6c37fe8d293b45bf32a16e412d06c376d0a042078279564c07ac2e", + height: 128, + }, + destruction: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: -1, + }, + collateralAddress: "bcrt1qyrfrpadwgw7p5eh3e9h3jmu4kwlz4prx73cqny", + displaySymbol: "dTD10", + isLoanToken: true, + }, + interest: "1.5", + fixedIntervalPriceId: "TD10/USD", + }, + { + tokenId: + "b99d32217b8fbe8872015c1a376f745a94372f061a0fb88c9b212876e4f158f5", + token: { + id: "14", + symbol: "DUSD", + symbolKey: "DUSD", + name: "Decentralized USD", + decimal: 8, + limit: "0", + mintable: true, + tradeable: true, + isDAT: true, + isLPS: false, + finalized: false, + minted: "20540", + creation: { + tx: "b99d32217b8fbe8872015c1a376f745a94372f061a0fb88c9b212876e4f158f5", + height: 128, + }, + destruction: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: -1, + }, + collateralAddress: "bcrt1qyrfrpadwgw7p5eh3e9h3jmu4kwlz4prx73cqny", + displaySymbol: "DUSD", + isLoanToken: true, + }, + interest: "0", + fixedIntervalPriceId: "DUSD/USD", + }, + { + tokenId: + "ffbaea57f155a36700c65a018aafe5e7d9984416339fb623df887f1a82b12142", + token: { + id: "11", + symbol: "TR50", + symbolKey: "TR50", + name: "Decentralized TR50", + decimal: 8, + limit: "0", + mintable: true, + tradeable: true, + isDAT: true, + isLPS: false, + finalized: false, + minted: "10.27", + creation: { + tx: "ffbaea57f155a36700c65a018aafe5e7d9984416339fb623df887f1a82b12142", + height: 128, + }, + destruction: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: -1, + }, + collateralAddress: "bcrt1qyrfrpadwgw7p5eh3e9h3jmu4kwlz4prx73cqny", + displaySymbol: "dTR50", + isLoanToken: true, + }, + interest: "3", + fixedIntervalPriceId: "TR50/USD", + }, + ]; + const actual = loanTokensSelector(state); + expect(actual).toStrictEqual(loanTokensWithDUSDActivePrice); + }); + + it("should be able to select loan token by token ID", () => { + const state = { + ...initialState, + loanTokens, + }; + const actual = loanTokenByTokenId(state, "14"); + expect(actual).toStrictEqual({ + tokenId: + "b99d32217b8fbe8872015c1a376f745a94372f061a0fb88c9b212876e4f158f5", + token: { + id: "14", + symbol: "DUSD", + symbolKey: "DUSD", + name: "Decentralized USD", + decimal: 8, + limit: "0", + mintable: true, + tradeable: true, + isDAT: true, + isLPS: false, + finalized: false, + minted: "20540", + creation: { + tx: "b99d32217b8fbe8872015c1a376f745a94372f061a0fb88c9b212876e4f158f5", + height: 128, + }, + destruction: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: -1, + }, + collateralAddress: "bcrt1qyrfrpadwgw7p5eh3e9h3jmu4kwlz4prx73cqny", + displaySymbol: "DUSD", + isLoanToken: true, + }, + interest: "0", + fixedIntervalPriceId: "DUSD/USD", + }); + }); + + it("should be able to select vaults regardless of vault state", () => { + const liquidatedVault: LoanVaultLiquidated & { vaultState: VaultStatus } = { + vaultId: + "eee84f2cc56bbc51a42eaf302b76d4d1250b58b943829ee82f2fa9a46a9e4319", + loanScheme: { + id: "MIN150", + minColRatio: "150", + interestRate: "5", + }, + ownerAddress: "bcrt1q39r84tmh4xp7wmg32tnza8j544lynknvy8q2nr", + state: LoanVaultState.IN_LIQUIDATION, + vaultState: VaultStatus.Liquidated, + liquidationHeight: 1, + liquidationPenalty: 1, + batchCount: 1, + batches: [], + }; + const state = { + ...initialState, + vaults: [liquidatedVault], + }; + const actual = vaultsSelector(state); + expect(actual).toStrictEqual([liquidatedVault]); + }); + + it("should be able to select vaults that returns DUSD loan and interest with active price", () => { + const state = { + ...initialState, + vaults: [vault], + }; + const actual = vaultsSelector(state); + expect(actual).toStrictEqual([ + { + ...vault, + loanAmounts: [ + { + id: "14", + amount: "1.00000570", + symbol: "DUSD", + symbolKey: "DUSD", + name: "Decentralized USD", + displaySymbol: "DUSD", + }, + { + id: "13", + amount: "0.00000001", + symbol: "TD10", + symbolKey: "TD10", + name: "Decentralized TD10", + displaySymbol: "dTD10", + }, + ], + interestAmounts: [ + { + id: "14", + amount: "0.00000570", + symbol: "DUSD", + symbolKey: "DUSD", + name: "Decentralized USD", + displaySymbol: "DUSD", + }, + { + id: "13", + amount: "0.00000000", + symbol: "TD10", + symbolKey: "TD10", + name: "Decentralized TD10", + displaySymbol: "dTD10", + }, + ], + vaultState: "HEALTHY", + }, + ]); + }); +}); diff --git a/packages/walletkit-ui/src/store/types/VaultStatus.ts b/packages/walletkit-ui/src/store/types/VaultStatus.ts new file mode 100644 index 0000000..1fd385e --- /dev/null +++ b/packages/walletkit-ui/src/store/types/VaultStatus.ts @@ -0,0 +1,33 @@ +import BigNumber from "bignumber.js"; + +export interface CollateralizationRatioProps { + colRatio: BigNumber; + minColRatio: BigNumber; + totalLoanAmount: BigNumber; + totalCollateralValue?: BigNumber; +} + +export interface CollateralizationRatioStats { + atRiskThreshold: BigNumber; + liquidatedThreshold: BigNumber; + isInLiquidation: boolean; + isAtRisk: boolean; + isHealthy: boolean; + isReady: boolean; +} + +export enum VaultStatus { + Empty = "EMPTY", + Ready = "READY", + Healthy = "HEALTHY", + AtRisk = "AT RISK", + Halted = "HALTED", + NearLiquidation = "NEAR LIQUIDATION", + Liquidated = "IN LIQUIDATION", + Unknown = "UNKNOWN", +} + +export interface VaultHealthItem { + vaultStats: CollateralizationRatioStats; + status: VaultStatus; +} diff --git a/packages/walletkit-ui/src/store/types/index.ts b/packages/walletkit-ui/src/store/types/index.ts new file mode 100644 index 0000000..5361bb4 --- /dev/null +++ b/packages/walletkit-ui/src/store/types/index.ts @@ -0,0 +1 @@ +export * from "./VaultStatus"; diff --git a/packages/walletkit-ui/src/store/userPreferences.ts b/packages/walletkit-ui/src/store/userPreferences.ts new file mode 100644 index 0000000..f207d6e --- /dev/null +++ b/packages/walletkit-ui/src/store/userPreferences.ts @@ -0,0 +1,130 @@ +/* eslint-disable */ + +import { + createAsyncThunk, + createSelector, + createSlice, + PayloadAction, +} from "@reduxjs/toolkit"; +import { EnvironmentNetwork } from "@waveshq/walletkit-core"; + +export interface LabeledAddress { + [address: string]: LocalAddress; +} + +export interface LocalAddress { + address: string; + label: string; + isMine: boolean; + isFavourite?: boolean; +} + +export interface UserPreferences { + addresses: LabeledAddress; + addressBook: LabeledAddress; +} + +const prepopulateField = (addresses: LabeledAddress): LocalAddress[] => { + const _addresses: LabeledAddress = { ...addresses }; + + // pre-populate address and isFavourite flag for older app version, used for UI data model only + for (const address in addresses) { + if (addresses[address].address === undefined) { + const _address = { + ...addresses[address], + address, + isFavourite: false, + }; + _addresses[address] = _address; + } + } + return Object.values(_addresses); +}; + +const initialState: UserPreferences = { + addresses: {}, + addressBook: {}, +}; + +export const fetchUserPreferences = createAsyncThunk( + "userPreferences/fetchUserPreferences", + // TODO @julio replace with type + async (network: EnvironmentNetwork, localStorage: any) => + await localStorage.getUserPreferences(network) +); + +export const setUserPreferences = createAsyncThunk( + "userPreferences/setUserPreferences", + async ({ + network, + preferences, + localStorage, + }: { + network: EnvironmentNetwork; + preferences: UserPreferences; + // TODO @julio replace with type + localStorage: any; + }) => { + await localStorage.setUserPreferences(network, preferences); + } +); + +export const setAddresses = createAsyncThunk( + "userPreferences/setAddresses", + async (addresses: LabeledAddress) => addresses +); + +export const setAddressBook = createAsyncThunk( + "userPreferences/setAddressBook", + async (addressBook: LabeledAddress) => addressBook +); + +export const userPreferences = createSlice({ + name: "userPreferences", + initialState, + reducers: { + addToAddressBook: (state, action: PayloadAction) => { + state.addressBook = { + ...state.addressBook, + ...action.payload, + }; + }, + deleteFromAddressBook: (state, action: PayloadAction) => { + const { [action.payload]: _, ...newAddressBook } = state.addressBook; + state.addressBook = newAddressBook; + }, + }, + extraReducers: (builder) => { + builder.addCase( + fetchUserPreferences.fulfilled, + (state, action: PayloadAction) => { + state = action.payload; + return state; + } + ); + builder.addCase( + setAddresses.fulfilled, + (state, action: PayloadAction) => { + state.addresses = action.payload; + return state; + } + ); + builder.addCase( + setAddressBook.fulfilled, + (state, action: PayloadAction) => { + state.addressBook = action.payload; + return state; + } + ); + }, +}); + +export const selectAddressBookArray = createSelector( + (state: UserPreferences) => state.addressBook, + (addressBook) => prepopulateField(addressBook) +); + +export const selectLocalWalletAddressArray = createSelector( + (state: UserPreferences) => state.addresses, + (walletAddress) => prepopulateField(walletAddress) +);