diff --git a/packages/walletkit-core/src/api/index.ts b/packages/walletkit-core/src/api/index.ts index 8e06445..6d5499a 100644 --- a/packages/walletkit-core/src/api/index.ts +++ b/packages/walletkit-core/src/api/index.ts @@ -1,3 +1,4 @@ export * from "./environment"; export * from "./network"; +export * from "./paginatedAPI"; export * from "./whale"; diff --git a/packages/walletkit-core/src/api/paginatedAPI.ts b/packages/walletkit-core/src/api/paginatedAPI.ts new file mode 100644 index 0000000..cda4490 --- /dev/null +++ b/packages/walletkit-core/src/api/paginatedAPI.ts @@ -0,0 +1,22 @@ +import { ApiPagedResponse } from "@defichain/whale-api-client"; + +export async function getPaginatedResponse( + api: (limit: number, next?: string) => Promise> +): Promise { + const current = []; + let hasNext = false; + let next; + + try { + do { + // eslint-disable-next-line no-await-in-loop + const data: ApiPagedResponse = await api(200, next); + current.push(...data); + hasNext = data.hasNext; + next = data.nextToken; + } while (hasNext); + } catch (e) { + return current; + } + return current; +} diff --git a/packages/walletkit-core/src/api/paginatedAPI.unit.ts b/packages/walletkit-core/src/api/paginatedAPI.unit.ts new file mode 100644 index 0000000..5b05644 --- /dev/null +++ b/packages/walletkit-core/src/api/paginatedAPI.unit.ts @@ -0,0 +1,12 @@ +import { getPaginatedResponse } from "./paginatedAPI"; + +describe("Paginated API", () => { + it("should receive correct value", async () => { + const sampleData = [{ name: "John" }]; + const mockAPI = jest.fn().mockResolvedValue(sampleData); + const response = await getPaginatedResponse((limit, next) => + mockAPI(limit, next) + ); + expect(response).toEqual(sampleData); + }); +}); diff --git a/packages/walletkit-ui/src/store/index.ts b/packages/walletkit-ui/src/store/index.ts index 59334f9..1db5416 100644 --- a/packages/walletkit-ui/src/store/index.ts +++ b/packages/walletkit-ui/src/store/index.ts @@ -1,3 +1,5 @@ export * from "./block"; export * from "./ocean"; export * from "./transaction_queue"; +export * from "./wallet"; +export * from "./website"; diff --git a/packages/walletkit-ui/src/store/wallet.ts b/packages/walletkit-ui/src/store/wallet.ts new file mode 100644 index 0000000..9cd6811 --- /dev/null +++ b/packages/walletkit-ui/src/store/wallet.ts @@ -0,0 +1,375 @@ +import { WhaleApiClient } from "@defichain/whale-api-client"; +import { AddressToken } from "@defichain/whale-api-client/dist/api/address"; +import { + AllSwappableTokensResult, + DexPrice, + PoolPairData, +} from "@defichain/whale-api-client/dist/api/poolpairs"; +import { TokenData } from "@defichain/whale-api-client/dist/api/tokens"; +import { + createAsyncThunk, + createSelector, + createSlice, + PayloadAction, +} from "@reduxjs/toolkit"; +import { getPaginatedResponse } from "@waveshq/walletkit-core"; +import BigNumber from "bignumber.js"; + +interface AssociatedToken { + [key: string]: TokenData; +} + +export interface SwappableTokens { + [key: string]: AllSwappableTokensResult; +} + +interface DexPricesProps { + [symbol: string]: DexPrice; +} + +export enum AddressType { + WalletAddress, + Whitelisted, + OthersButValid, +} + +export interface WalletState { + utxoBalance: string; + tokens: WalletToken[]; + allTokens: AssociatedToken; + poolpairs: DexItem[]; + dexPrices: { [symbol: string]: DexPricesProps }; + swappableTokens: SwappableTokens; + hasFetchedPoolpairData: boolean; + hasFetchedToken: boolean; + hasFetchedSwappableTokens: boolean; +} + +export interface WalletToken extends AddressToken { + avatarSymbol: string; + usdAmount?: BigNumber; +} + +export interface DexItem { + type: "your" | "available"; + data: PoolPairData; +} + +const initialState: WalletState = { + utxoBalance: "0", + tokens: [], + allTokens: {}, + poolpairs: [], + dexPrices: {}, + swappableTokens: {}, + hasFetchedSwappableTokens: false, + hasFetchedPoolpairData: false, + hasFetchedToken: false, +}; + +const tokenDFI: WalletToken = { + id: "0", + symbol: "DFI", + symbolKey: "DFI", + isDAT: true, + isLPS: false, + isLoanToken: false, + amount: "0", + name: "DeFiChain", + displaySymbol: "DFI (Token)", + avatarSymbol: "DFI (Token)", +}; + +const utxoDFI: WalletToken = { + ...tokenDFI, + id: "0_utxo", + displaySymbol: "DFI (UTXO)", + avatarSymbol: "DFI (UTXO)", +}; + +const unifiedDFI: WalletToken = { + ...tokenDFI, + id: "0_unified", + displaySymbol: "DFI", + avatarSymbol: "DFI", +}; + +/** + * Recursively get all tokens based on pagination info from ApiPagedResponse class + */ +const getAllTokens = async (client: WhaleApiClient): Promise => { + const allTokens: TokenData[] = await getPaginatedResponse( + (limit, next) => client.tokens.list(limit, next) + ); + return allTokens.filter((token) => token.isDAT); +}; + +export const setTokenSymbol = (t: AddressToken): WalletToken => { + let { displaySymbol } = t; + let avatarSymbol = t.displaySymbol; + if (t.id === "0") { + t.name = "DeFiChain"; + displaySymbol = "DFI (Token)"; + } + if (t.id === "0_utxo") { + displaySymbol = "DFI (UTXO)"; + } + if (t.isLPS) { + t.name = t.name.replace("Default Defi token", "DeFiChain"); + avatarSymbol = t.symbol; + } + return { + ...t, + displaySymbol, + avatarSymbol, + }; +}; + +const associateTokens = (tokens: TokenData[]): AssociatedToken => { + const result: AssociatedToken = {}; + tokens.forEach((token) => { + if (token.isDAT) { + result[token.displaySymbol] = token; + } + }); + return result; +}; + +export const fetchPoolPairs = createAsyncThunk( + "wallet/fetchPoolPairs", + async ({ + size = 200, + client, + }: { + size?: number; + client: WhaleApiClient; + }): Promise => { + const pairs = await client.poolpairs.list(size); + return pairs.map((data) => ({ + type: "available", + data, + })); + } +); + +export const fetchDexPrice = createAsyncThunk( + "wallet/fetchDexPrice", + async ({ + client, + denomination, + }: { + size?: number; + client: WhaleApiClient; + denomination: string; + }): Promise<{ dexPrices: DexPricesProps; denomination: string }> => { + const { dexPrices } = await client.poolpairs.listDexPrices(denomination); + return { + dexPrices, + denomination, + }; + } +); + +export const fetchTokens = createAsyncThunk( + "wallet/fetchTokens", + async ({ + size = 200, + address, + client, + }: { + size?: number; + address: string; + client: WhaleApiClient; + }): Promise<{ + tokens: AddressToken[]; + allTokens: TokenData[]; + utxoBalance: string; + }> => { + const tokens = await client.address.listToken(address, size); + const allTokens = await getAllTokens(client); + const utxoBalance = await client.address.getBalance(address); + return { + tokens, + allTokens, + utxoBalance, + }; + } +); + +export const fetchSwappableTokens = createAsyncThunk( + "wallet/swappableTokens", + async ({ + client, + fromTokenId, + }: { + client: WhaleApiClient; + fromTokenId: string; + }): Promise => + client.poolpairs.getSwappableTokens(fromTokenId) +); + +export const wallet = createSlice({ + name: "wallet", + initialState, + reducers: { + setHasFetchedToken: (state, action: PayloadAction) => { + state.hasFetchedToken = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase( + fetchPoolPairs.fulfilled, + (state, action: PayloadAction) => { + state.hasFetchedPoolpairData = true; + state.poolpairs = action.payload.filter( + ({ data }) => !data.symbol.includes("/v1") + ); // Filter out v1 pairs due to stock split + } + ); + builder.addCase( + fetchDexPrice.fulfilled, + ( + state, + action: PayloadAction<{ + dexPrices: DexPricesProps; + denomination: string; + }> + ) => { + state.dexPrices = { + ...state.dexPrices, + [action.payload.denomination]: action.payload.dexPrices, + }; + } + ); + builder.addCase( + fetchTokens.fulfilled, + ( + state, + action: PayloadAction<{ + tokens: AddressToken[]; + allTokens: TokenData[]; + utxoBalance: string; + }> + ) => { + state.hasFetchedToken = true; + state.tokens = action.payload.tokens.map(setTokenSymbol); + state.utxoBalance = action.payload.utxoBalance; + state.allTokens = associateTokens( + action.payload.allTokens.filter( + (token) => !token.symbol.includes("/v1") + ) + ); // Filter out v1 tokens due to stock split + } + ); + builder.addCase( + fetchSwappableTokens.fulfilled, + (state, action: PayloadAction) => { + state.hasFetchedSwappableTokens = true; + state.swappableTokens = { + ...state.swappableTokens, + ...{ + [action.payload.fromToken.id]: action.payload, + }, + }; + } + ); + }, +}); + +const rawTokensSelector = createSelector( + (state: WalletState) => state.tokens, + (tokens) => { + const rawTokens: WalletToken[] = []; + if (!tokens.some((t) => t.id === "0_utxo")) { + rawTokens.push(utxoDFI); + } + if (!tokens.some((t) => t.id === "0")) { + rawTokens.push(tokenDFI); + } + if (!tokens.some((t) => t.id === "0_unified")) { + rawTokens.push(unifiedDFI); + } + return [...rawTokens, ...tokens]; + } +); + +export const tokensSelector = createSelector( + [rawTokensSelector, (state: WalletState) => state.utxoBalance], + (tokens, utxoBalance) => { + const utxoAmount = new BigNumber(utxoBalance); + const tokenAmount = new BigNumber( + (tokens.find((t) => t.id === "0") ?? tokenDFI).amount + ); + return tokens.map((t) => { + if (t.id === "0_utxo") { + return { + ...t, + amount: utxoAmount.toFixed(8), + }; + } + if (t.id === "0_unified") { + return { + ...t, + amount: utxoAmount.plus(tokenAmount).toFixed(8), + }; + } + return t; + }); + } +); + +export const DFITokenSelector = createSelector( + tokensSelector, + (tokens) => tokens.find((token) => token.id === "0") ?? tokenDFI +); + +export const DFIUtxoSelector = createSelector( + tokensSelector, + (tokens) => tokens.find((token) => token.id === "0_utxo") ?? utxoDFI +); + +export const unifiedDFISelector = createSelector( + tokensSelector, + (tokens) => tokens.find((token) => token.id === "0_unified") ?? unifiedDFI +); + +const selectTokenId = (state: WalletState, tokenId: string): string => tokenId; + +/** + * Get single token by `id` from wallet store. + * To get DFI Token or DFI UTXO, use `DFITokenSelector` or `DFIUtxoSelector` instead + */ +export const tokenSelector = createSelector( + [tokensSelector, selectTokenId], + (tokens, tokenId) => + tokens.find((token) => { + if (tokenId === "0" || tokenId === "0_utxo") { + return token.id === "0_unified"; + } + return token.id === tokenId; + }) +); + +/** + * Get single token detail by `displaySymbol` from wallet store. + */ +export const tokenSelectorByDisplaySymbol = createSelector( + [(state: WalletState) => state.allTokens, selectTokenId], + (allTokens, displaySymbol) => allTokens[displaySymbol] +); + +/** + * Get dexprices by currency denomination + */ +export const dexPricesSelectorByDenomination = createSelector( + [(state: WalletState) => state.dexPrices, selectTokenId], + (dexPrices, denomination) => dexPrices[denomination] ?? {} +); + +/** + * Get single poolpair by id + */ +export const poolPairSelector = createSelector( + [(state: WalletState) => state.poolpairs, selectTokenId], + (poolpairs, id) => poolpairs.find((pair) => pair.data.id === id) +); diff --git a/packages/walletkit-ui/src/store/wallet.unit.ts b/packages/walletkit-ui/src/store/wallet.unit.ts new file mode 100644 index 0000000..4530ba0 --- /dev/null +++ b/packages/walletkit-ui/src/store/wallet.unit.ts @@ -0,0 +1,316 @@ +import { TokenData } from "@defichain/whale-api-client/dist/api/tokens"; + +import { + DexItem, + fetchPoolPairs, + fetchTokens, + tokensSelector, + wallet, + WalletState, + WalletToken, +} from "./wallet"; + +describe("wallet reducer", () => { + let initialState: WalletState; + let tokenDFI: WalletToken; + let utxoDFI: WalletToken; + let unifiedDFI: WalletToken; + let detailedDFI: TokenData; + + const dfi = { + id: "0", + isDAT: true, + isLPS: false, + isLoanToken: false, + name: "DeFiChain", + symbol: "DFI", + symbolKey: "DFI", + displaySymbol: "DFI (Token)", + avatarSymbol: "DFI (Token)", + }; + + beforeEach(() => { + initialState = { + tokens: [], + allTokens: {}, + utxoBalance: "0", + poolpairs: [], + dexPrices: {}, + swappableTokens: {}, + hasFetchedPoolpairData: false, + hasFetchedToken: true, + hasFetchedSwappableTokens: false, + }; + tokenDFI = { + ...dfi, + amount: "100000", + }; + utxoDFI = { + ...tokenDFI, + amount: "0", + id: "0_utxo", + displaySymbol: "DFI (UTXO)", + avatarSymbol: "DFI (UTXO)", + }; + unifiedDFI = { + ...tokenDFI, + amount: "0", + id: "0_unified", + displaySymbol: "DFI", + avatarSymbol: "DFI", + }; + detailedDFI = { + ...dfi, + creation: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: 0, + }, + decimal: 8, + destruction: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: -1, + }, + finalized: true, + limit: "0", + mintable: false, + minted: "0", + tradeable: true, + }; + }); + + it("should handle initial state", () => { + expect(wallet.reducer(undefined, { type: "unknown" })).toEqual({ + utxoBalance: "0", + tokens: [], + allTokens: {}, + poolpairs: [], + dexPrices: {}, + swappableTokens: {}, + hasFetchedPoolpairData: false, + hasFetchedSwappableTokens: false, + hasFetchedToken: false, + }); + }); + + it("should handle setTokens and setUtxoBalance", () => { + const tokens: WalletToken[] = [tokenDFI, utxoDFI]; + const allTokens = { + "DFI (Token)": detailedDFI, + }; + + const utxoBalance = "77"; + const action = { + type: fetchTokens.fulfilled.type, + payload: { tokens, utxoBalance, allTokens: [detailedDFI] }, + }; + const actual = wallet.reducer(initialState, action); + expect(actual.tokens).toStrictEqual(tokens); + expect(actual.utxoBalance).toStrictEqual("77"); + expect(actual.allTokens).toStrictEqual(allTokens); + }); + + it("should filter out v1 tokens", () => { + const allTokens: TokenData[] = [ + { + id: "0", + symbol: "AMZN/v1", + symbolKey: "AMZN/v1", + name: "dAMZN", + decimal: 8, + limit: "0", + mintable: false, + tradeable: false, + isDAT: true, + isLPS: false, + isLoanToken: false, + finalized: true, + minted: "0", + creation: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: 1, + }, + destruction: { + tx: "0000000000000000000000000000000000000000000000000000000000000000", + height: 0, + }, + displaySymbol: "dAMZN/v1", + }, + ]; + const tokens: WalletToken[] = [tokenDFI, utxoDFI]; + const utxoBalance = "77"; + + const action = { + type: fetchTokens.fulfilled.type, + payload: { tokens, utxoBalance, allTokens }, + }; + const actual = wallet.reducer(initialState, action); + expect(Object.keys(actual.allTokens).length).toStrictEqual(0); + }); + + it("should handle setPoolpairs", () => { + const payload: DexItem[] = [ + { + type: "available", + data: { + id: "8", + symbol: "DFI-USDT", + name: "Default Defi token-Playground USDT", + status: true, + displaySymbol: "dUSDT-DFI", + tokenA: { + name: "DeFiChain", + id: "0", + reserve: "1000", + blockCommission: "0", + symbol: "DFI", + displaySymbol: "dDFI", + }, + tokenB: { + name: "Tether", + id: "3", + reserve: "10000000", + blockCommission: "0", + symbol: "USDT", + displaySymbol: "dUSDT", + }, + priceRatio: { + ab: "0.0001", + ba: "10000", + }, + commission: "0", + totalLiquidity: { + token: "100000", + usd: "20000000", + }, + tradeEnabled: true, + ownerAddress: "mswsMVsyGMj1FzDMbbxw2QW3KvQAv2FKiy", + rewardPct: "0.2", + creation: { + tx: "f691c8b0a5d362a013a7207228e618d832c0b99af8da99c847923f5f93136d60", + height: 119, + }, + apr: { + reward: 133.7652, + total: 133.7652, + commission: 0, + }, + rewardLoanPct: "0", + }, + }, + ]; + + const action = { type: fetchPoolPairs.fulfilled.type, payload }; + const actual = wallet.reducer(initialState, action); + expect(actual.poolpairs).toStrictEqual(payload); + }); + + it("should filter out v1 Poolpairs", () => { + const payload: DexItem[] = [ + { + type: "available", + data: { + id: "8", + symbol: "AMZN-DUSD/v1", + name: "dAMZN-Decentralized USD", + status: true, + displaySymbol: "dAMZN-dDUSD/v1", + tokenA: { + id: "0", + reserve: "1000", + blockCommission: "0", + symbol: "AMZN", + displaySymbol: "dAMZN", + name: "dAmazon", + }, + tokenB: { + id: "3", + reserve: "10000000", + blockCommission: "0", + symbol: "DUSD/v1", + displaySymbol: "dDUSD/v1", + name: "Old DUSD", + }, + priceRatio: { + ab: "0.0001", + ba: "10000", + }, + commission: "0", + totalLiquidity: { + token: "100000", + usd: "20000000", + }, + tradeEnabled: true, + ownerAddress: "mswsMVsyGMj1FzDMbbxw2QW3KvQAv2FKiy", + rewardPct: "0.2", + creation: { + tx: "f691c8b0a5d362a013a7207228e618d832c0b99af8da99c847923f5f93136d60", + height: 119, + }, + apr: { + reward: 133.7652, + total: 133.7652, + commission: 0, + }, + rewardLoanPct: "0", + }, + }, + ]; + const action = { type: fetchPoolPairs.fulfilled.type, payload }; + const actual = wallet.reducer(initialState, action); + expect(actual.poolpairs.length).toStrictEqual(0); + }); + + it("should able to select tokens with default DFIs", () => { + const actual = tokensSelector({ + ...initialState, + utxoBalance: "77", + }); + expect(actual).toStrictEqual([ + { + ...utxoDFI, + amount: "77.00000000", + }, + { + ...tokenDFI, + amount: "0", + }, + { + ...unifiedDFI, + amount: "77.00000000", + }, + ]); + }); + + it("should able to select tokens with existing DFI Token", () => { + const btc = { + id: "1", + isLPS: false, + name: "Bitcoin", + isDAT: true, + symbol: "BTC", + symbolKey: "BTC", + amount: "1", + displaySymbol: "BTC", + avatarSymbol: "BTC", + isLoanToken: false, + }; + const state = { + ...initialState, + utxoBalance: "77.00000000", + tokens: [{ ...utxoDFI }, { ...tokenDFI }, { ...unifiedDFI }, { ...btc }], + }; + const actual = tokensSelector(state); + expect(actual).toStrictEqual([ + { + ...utxoDFI, + amount: "77.00000000", + }, + { ...tokenDFI }, + { + ...unifiedDFI, + amount: "100077.00000000", + }, + { ...btc }, + ]); + }); +}); diff --git a/packages/walletkit-ui/src/store/website.ts b/packages/walletkit-ui/src/store/website.ts new file mode 100644 index 0000000..aeca8ba --- /dev/null +++ b/packages/walletkit-ui/src/store/website.ts @@ -0,0 +1,70 @@ +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; +import { + AnnouncementData, + DeFiChainStatus, + FeatureFlag, +} from "@waveshq/walletkit-core"; + +export const statusWebsiteSlice = createApi({ + reducerPath: "websiteStatus", + baseQuery: fetchBaseQuery({ + baseUrl: "https://api.status.jellyfishsdk.com", + }), + endpoints: (builder) => ({ + getBlockchainStatus: builder.query({ + query: () => ({ + url: "/blockchain", + method: "GET", + }), + }), + // Ocean API + getOceanStatus: builder.query({ + query: () => ({ + url: "/overall", + method: "GET", + }), + }), + }), +}); + +export const announcementWebsiteSlice = createApi({ + reducerPath: "website", + baseQuery: fetchBaseQuery({ + baseUrl: "https://wallet.defichain.com/api/v0", + }), + endpoints: (builder) => ({ + getAnnouncements: builder.query({ + query: () => ({ + url: "/announcements", + method: "GET", + headers: { + "Access-Control-Allow-Origin": "*", + mode: "no-cors", + }, + }), + }), + getFeatureFlags: builder.query({ + query: () => ({ + url: "/settings/flags", + method: "GET", + headers: { + "Access-Control-Allow-Origin": "*", + mode: "no-cors", + }, + }), + }), + }), +}); + +const { useGetBlockchainStatusQuery, useGetOceanStatusQuery } = + statusWebsiteSlice; +const { useGetAnnouncementsQuery, useGetFeatureFlagsQuery, usePrefetch } = + announcementWebsiteSlice; + +export { + useGetAnnouncementsQuery, + useGetBlockchainStatusQuery, + useGetFeatureFlagsQuery, + useGetOceanStatusQuery, + usePrefetch, +};