diff --git a/package.json b/package.json index 67efde5c..8c1b15d5 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "bootstrap": "^5.2.3", "bs58": "^5.0.0", "buffer": "^6.0.3", + "class-transformer": "^0.5.1", "clipboard": "^2.0.11", "crypto-browserify": "^3.12.0", "crypto-js": "^4.1.1", @@ -40,6 +41,7 @@ "react-toastify": "^9.1.1", "react-transition-group": "^4.4.2", "redux": "^4.0.5", + "reflect-metadata": "^0.1.13", "sass": "^1.52.1", "stream-browserify": "^3.0.0", "typescript": "^4.9.5", diff --git a/src/assets/images/arrow.svg b/src/assets/images/arrow.svg new file mode 100644 index 00000000..bbc821ad --- /dev/null +++ b/src/assets/images/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/messageTypes/claimPrize.svg b/src/assets/images/messageTypes/claimPrize.svg new file mode 100644 index 00000000..0070ae60 --- /dev/null +++ b/src/assets/images/messageTypes/claimPrize.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/messageTypes/millionsDeposit.svg b/src/assets/images/messageTypes/millionsDeposit.svg new file mode 100644 index 00000000..7814176f --- /dev/null +++ b/src/assets/images/messageTypes/millionsDeposit.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/images/messageTypes/millionsWithdraw.svg b/src/assets/images/messageTypes/millionsWithdraw.svg new file mode 100644 index 00000000..62ee9bd8 --- /dev/null +++ b/src/assets/images/messageTypes/millionsWithdraw.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/images/tokens/atom.svg b/src/assets/images/tokens/atom.svg new file mode 100644 index 00000000..e93efb3a --- /dev/null +++ b/src/assets/images/tokens/atom.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/images/tokens/dfr.svg b/src/assets/images/tokens/dfr.svg new file mode 100644 index 00000000..eb5274f4 --- /dev/null +++ b/src/assets/images/tokens/dfr.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/images/tokens/osmo.svg b/src/assets/images/tokens/osmo.svg new file mode 100644 index 00000000..e74dcf1e --- /dev/null +++ b/src/assets/images/tokens/osmo.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/tokens/usdc.svg b/src/assets/images/tokens/usdc.svg new file mode 100644 index 00000000..212b70e0 --- /dev/null +++ b/src/assets/images/tokens/usdc.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/index.ts b/src/assets/index.ts index 780e8eaf..ed38712f 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -19,6 +19,9 @@ import openBeam from './images/messageTypes/openBeam.svg'; import updateBeam from './images/messageTypes/updateBeam.svg'; import claimBeam from './images/messageTypes/claimBeam.svg'; import depositDfract from './images/messageTypes/dfract.svg'; +import depositMillions from './images/messageTypes/millionsDeposit.svg'; +import withdrawMillions from './images/messageTypes/millionsWithdraw.svg'; +import claimMillions from './images/messageTypes/claimPrize.svg'; import hardwareIcon from './images/hardware.svg'; import softwareIcon from './images/software.png'; import addIcon from './images/add.png'; @@ -57,9 +60,15 @@ import depositPeriod from './images/depositPeriod.svg'; import warningIcon from './images/warningIcon.svg'; import moreIcon from './images/more.svg'; import guardaIcon from './images/guarda.svg'; +import atomIcon from './images/tokens/atom.svg'; +import osmoIcon from './images/tokens/osmo.svg'; +import usdcIcon from './images/tokens/usdc.svg'; +import dfrIcon from './images/tokens/dfr.svg'; +import arrow from './images/arrow.svg'; export default { images: { + arrow, airdropCoins, hardwareIcon, softwareIcon, @@ -114,6 +123,9 @@ export default { claimBeam, setWithdrawAddress, depositDfract, + depositMillions, + withdrawMillions, + claimMillions, }, navbarIcons: { dashboard, @@ -123,5 +135,11 @@ export default { governance, logout, }, + tokens: { + atom: atomIcon, + dfr: dfrIcon, + osmo: osmoIcon, + usdc: usdcIcon, + }, }, }; diff --git a/src/components/Cards/BalanceCard.tsx b/src/components/Cards/BalanceCard.tsx index 93e81cb9..b996ec2c 100644 --- a/src/components/Cards/BalanceCard.tsx +++ b/src/components/Cards/BalanceCard.tsx @@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next'; import { NumbersUtils, WalletClient } from 'utils'; import { SmallerDecimal } from 'components'; import { Rewards } from 'models'; -import { CLIENT_PRECISION } from 'constant'; interface Props { balance: number; @@ -18,9 +17,7 @@ const BalanceCard = ({ balance, rewards }: Props): JSX.Element => { const lumAmount = balance + - (rewards.total && rewards.total.length > 0 - ? NumbersUtils.convertUnitNumber(rewards.total[0].amount) / CLIENT_PRECISION - : 0); + (rewards.total && rewards.total.length > 0 ? NumbersUtils.convertUnitNumber(rewards.total[0].amount) : 0); const fiatAmount = lumAmount * (WalletClient.getLumInfos()?.price || 0); return ( diff --git a/src/components/Collapsible/Collapsible.scss b/src/components/Collapsible/Collapsible.scss new file mode 100644 index 00000000..4da1bd8f --- /dev/null +++ b/src/components/Collapsible/Collapsible.scss @@ -0,0 +1,83 @@ +@import 'src/styles/main'; + +.collapsible { + padding: 20px 24px; + + .collapsible-title { + width: 100%; + font-style: normal; + font-weight: 400; + font-size: 18px; + line-height: 22px; + color: $color-black; + + display: flex; + justify-content: space-between; + align-items: center; + + position: relative; + + @include media-breakpoint-down(md) { + align-items: stretch; + justify-content: flex-start; + flex-direction: column; + } + + @media (prefers-color-scheme: dark) { + .collapsible-arrow { + filter: brightness(0) invert(1); + } + } + } + + .collapsible-content { + margin-top: 24px; + color: $color-black; + font-style: normal; + font-weight: 300; + font-size: 16px; + line-height: 19px; + } + + .collapsible-btn { + cursor: pointer; + width: 40px; + height: 40px; + + position: absolute; + right: 0; + + img { + width: 10px; + transition: all 0.25s ease-out; + transform-origin: center; + transform: rotate(90deg); + } + + &.with-button-border { + border: 2px solid $color-primary; + padding: 8px 18px; + font-size: 14px; + line-height: 17px; + color: $color-primary; + width: unset; + + @media (prefers-color-scheme: dark) { + color: $color-white; + border-color: $color-white; + } + } + + @include media-breakpoint-down(md) { + bottom: 0; + } + + &.is-showing img { + transform: rotate(270deg); + } + } + + &.clickable { + cursor: pointer; + } +} diff --git a/src/components/Collapsible/Collapsible.tsx b/src/components/Collapsible/Collapsible.tsx new file mode 100644 index 00000000..f04a3e33 --- /dev/null +++ b/src/components/Collapsible/Collapsible.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Collapse } from 'bootstrap'; + +import Assets from 'assets'; + +import './Collapsible.scss'; + +interface Props { + header: string | JSX.Element; + content: string | JSX.Element; + id: string; + disabled?: boolean; + withButton?: boolean; + toggleWithButton?: boolean; + className?: string; + border?: boolean; + buttonBorder?: boolean; + onExpand?: () => void; + onCollapse?: () => void; +} + +const Collapsible = (props: Props) => { + const [isShowing, setIsShowing] = useState(false); + const [bsCollapse, setBsCollapse] = useState(); + const collapseRef = useRef(null); + + const { t } = useTranslation(); + + const { + header, + content, + id, + className, + toggleWithButton, + disabled, + border = true, + buttonBorder, + onCollapse, + onExpand, + } = props; + + useEffect(() => { + const collapsible = document.getElementById(id); + + if (collapsible) { + setBsCollapse(new Collapse(collapsible, { toggle: false })); + + collapsible.addEventListener('hide.bs.collapse', () => { + setIsShowing(false); + }); + collapsible.addEventListener('show.bs.collapse', () => { + setIsShowing(true); + }); + } + }, []); + + const onToggle = (show: boolean) => { + if (disabled) { + return; + } + + if (show) { + bsCollapse?.show(); + onExpand && onExpand(); + } else { + bsCollapse?.hide(); + onCollapse && onCollapse(); + } + }; + + return ( +
onToggle(!isShowing)} + className={`collapsible ${!toggleWithButton ? 'clickable' : ''} ${border ? 'with-border' : ''} ${ + isShowing && 'is-showing' + } ${className}`} + > +
+ {header} + {!disabled && ( +
onToggle(!isShowing) : undefined} + className={`collapsible-btn ${ + isShowing && 'is-showing' + } d-flex align-items-center justify-content-center ${ + buttonBorder ? 'with-button-border rounded-pill' : 'ms-4' + }`} + > + {buttonBorder && + (isShowing ? ( + + ) : ( + + ))} + arrow +
+ )} +
+
+ {typeof content === 'string' ? ( +

+ ) : ( + content + )} +

+
+ ); +}; + +export default Collapsible; diff --git a/src/components/Tables/OtherAssetsTable/OtherAssetsTable.scss b/src/components/Tables/OtherAssetsTable/OtherAssetsTable.scss new file mode 100644 index 00000000..5d7e0761 --- /dev/null +++ b/src/components/Tables/OtherAssetsTable/OtherAssetsTable.scss @@ -0,0 +1,13 @@ +@import 'src/styles/main'; + +.denom { + font-weight: bold; +} + +.usd-price { + color: #585858; + + @media (prefers-color-scheme: dark) { + color: $color-grey; + } +} \ No newline at end of file diff --git a/src/components/Tables/OtherAssetsTable/OtherAssetsTable.tsx b/src/components/Tables/OtherAssetsTable/OtherAssetsTable.tsx new file mode 100644 index 00000000..a6e0c465 --- /dev/null +++ b/src/components/Tables/OtherAssetsTable/OtherAssetsTable.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import numeral from 'numeral'; + +import { Table } from 'frontend-elements'; +import { OtherBalance } from 'models'; +import { RootState } from 'redux/store'; +import { DenomsUtils, NumbersUtils } from 'utils'; + +import SmallerDecimal from '../../SmallerDecimal/SmallerDecimal'; + +import './OtherAssetsTable.scss'; + +const OtherAssetsTable = ({ otherBalances }: { otherBalances: OtherBalance[] }) => { + const { t } = useTranslation(); + + const prices = useSelector((state: RootState) => state.stats.prices); + + const headers = t('dashboard.otherBalancesTable.headers', { returnObjects: true }); + + const renderRow = (otherBalance: OtherBalance) => { + const icon = DenomsUtils.getIconFromDenom(otherBalance.denom); + const price = prices.find((p) => p.denom === otherBalance.denom); + + return ( + + +
+ denom icon + {otherBalance.denom.toUpperCase()} +
+ + +
+
+ + {otherBalance.denom.toUpperCase()} +
+
+ {price && numeral(otherBalance.amount * price.price).format('$0,0[.]00')} +
+
+ + + ); + }; + + return {otherBalances.map((bal) => renderRow(bal))}
; +}; + +export default OtherAssetsTable; diff --git a/src/components/Tables/TransactionsTable.tsx b/src/components/Tables/TransactionsTable/TransactionsTable.tsx similarity index 84% rename from src/components/Tables/TransactionsTable.tsx rename to src/components/Tables/TransactionsTable/TransactionsTable.tsx index cab98b84..b0ebee5f 100644 --- a/src/components/Tables/TransactionsTable.tsx +++ b/src/components/Tables/TransactionsTable/TransactionsTable.tsx @@ -5,7 +5,7 @@ import { LumConstants } from '@lum-network/sdk-javascript'; import { Table } from 'frontend-elements'; import { Transaction, Wallet } from 'models'; -import { getExplorerLink, NumbersUtils, DenomsUtils, trunc } from 'utils'; +import { getExplorerLink, NumbersUtils, DenomsUtils, trunc, getMillionsLink } from 'utils'; import { SmallerDecimal, TransactionTypeBadge } from 'components'; import assets from 'assets'; @@ -70,6 +70,24 @@ const TransactionRow = (props: RowProps): JSX.Element => { ) : row.messages.length > 1 ? ( '-' + ) : row.toAddress.startsWith(t('transactions.pool')) ? ( + + {row.toAddress} + + ) : row.toAddress.startsWith(t('transactions.proposal')) ? ( + + {row.toAddress} + ) : ( trunc(row.toAddress || '-') )} diff --git a/src/components/index.ts b/src/components/index.ts index d66c0c9b..f418f488 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,7 +4,8 @@ export { default as AirdropCard } from './Cards/AirdropCard'; export { default as LumPriceCard } from './Cards/LumPriceCard'; export { default as BalanceCard } from './Cards/BalanceCard'; export { default as Card } from './Cards/Card'; -export { default as TransactionsTable } from './Tables/TransactionsTable'; +export { default as TransactionsTable } from './Tables/TransactionsTable/TransactionsTable'; +export { default as OtherAssetsTable } from './Tables/OtherAssetsTable/OtherAssetsTable'; export { default as Input } from './Inputs/Input'; export { default as SwitchInput } from './Inputs/SwitchInput'; export { default as HdPathInput } from './Inputs/HdPathInput'; @@ -20,3 +21,4 @@ export { default as HoverTooltip } from './Tooltip/HoverTooltip'; export { default as SmallerDecimal } from './SmallerDecimal/SmallerDecimal'; export { default as TransactionTypeBadge } from './TransactionTypeBadge/TransactionTypeBadge'; export { default as Badge } from './Badge/Badge'; +export { default as Collapsible } from './Collapsible/Collapsible'; diff --git a/src/constant/ibcDenoms.ts b/src/constant/ibcDenoms.ts index 23ceb813..cd428412 100644 --- a/src/constant/ibcDenoms.ts +++ b/src/constant/ibcDenoms.ts @@ -1,3 +1,6 @@ export const enum IBCDenoms { USDC = 'ibc/05554A9BFDD28894D7F18F4C707AA0930D778751A437A9FE1F4684A3E1199728', + OSMO = 'ibc/47BD209179859CDE4A2806763D7189B6E6FE13A17880FE2B42DE1E6C1E329E23', + ATOM = 'ibc/A8C2D23A1E6F95DA4E48BA349667E322BD7A6C996D8A4AAE8BA72E190F3D1477', + ATOM_TESTNET = 'ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2', } diff --git a/src/constant/index.ts b/src/constant/index.ts index fdbb0b18..07af9f7e 100644 --- a/src/constant/index.ts +++ b/src/constant/index.ts @@ -13,6 +13,8 @@ export const LUM_EXPLORER = 'https://explorer.lum.network'; export const LUM_WALLET = 'https://wallet.lum.network'; export const LUM_EXPLORER_TESTNET = 'https://explorer.testnet.lum.network'; export const LUM_WALLET_TESTNET = 'https://wallet.testnet.lum.network'; +export const LUM_MILLIONS = 'https://cosmosmillions.com'; +export const LUM_MILLIONS_TESTNET = 'https://testnet.cosmosmillions.com'; export const LUM_COINGECKO_ID = 'lum-network'; export const CLIENT_PRECISION = 1_000_000_000_000_000_000; diff --git a/src/index.tsx b/src/index.tsx index ba8069b5..7cb7ba13 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,5 @@ +import 'reflect-metadata'; + import React from 'react'; import { createRoot } from 'react-dom/client'; import reportWebVitals from './reportWebVitals'; diff --git a/src/locales/en.json b/src/locales/en.json index 5670fa81..52c287da 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -24,7 +24,9 @@ "reset": "Reset", "total": "Total", "day": "24h", - "more": "More" + "more": "More", + "openDetails": "Open details", + "closeDetails": "Close details" }, "navbar": { "dashboard": "Dashboard", @@ -74,6 +76,10 @@ "latestTx": "Latest Transactions", "currentBalance": "Your balance", "availableBalance": "Available balance", + "otherBalancesTable": { + "title": "My Other Assets", + "headers": ["Asset", "Balance"] + }, "followTwitter": "Follow us on Twitter", "noTx": "No transactions yet" }, @@ -230,9 +236,15 @@ "ibcReceivePacket": "IBC Receive Packet", "exec": "Exec", "grant": "Grant", - "depositDfract": "DFract Deposit" + "depositDfract": "DFract Deposit", + "depositMillions": "Millions Deposit", + "claimMillions": "Millions Claim Prize", + "withdrawMillions": "Millions Leave Pool", + "depositRetryMillions": "Millions Deposit Retry", + "withdrawRetryMillions": "Millions Withdraw Retry" }, - "proposal": "Proposal #" + "proposal": "Proposal #", + "pool": "Pool #" }, "operations": { "types": { @@ -259,6 +271,9 @@ "getAllRewards": { "name": "Get All Rewards" }, + "getOtherRewards": { + "name": "Get Other Rewards" + }, "vote": { "name": "Vote", "description": "Vote on a governance proposal" @@ -327,6 +342,9 @@ "unbondedTokens": "Unbonding Tokens", "vestedTokens": "Vesting Tokens", "rewards": "Rewards", + "otherStakingRewards": { + "title": "Other staking rewards" + }, "myValidators": { "title": "My validators", "empty": "No delegations yet" @@ -344,7 +362,8 @@ "votingPower": "Voting Power", "commission": "Commission", "stakedCoins": "Staked Coins", - "rewards": "Rewards" + "rewards": "Rewards", + "token": "Token" }, "status": ["Unspecified", "Unbonded", "Unbonding", "Bonded", "Jailed"], "claim": "Claim", diff --git a/src/models/Metadata.ts b/src/models/Metadata.ts new file mode 100644 index 00000000..b205df11 --- /dev/null +++ b/src/models/Metadata.ts @@ -0,0 +1,27 @@ +import { Exclude, Expose } from 'class-transformer'; + +@Exclude() +export class MetadataModel { + @Expose({ name: 'has_next_page' }) + hasNextPage!: boolean; + + @Expose({ name: 'has_previous_page' }) + hasPreviousPage!: boolean; + + @Expose({ name: 'items_count' }) + itemsCount!: number; + + @Expose({ name: 'items_total' }) + itemsTotal!: number; + + @Expose() + limit!: number; + + @Expose() + page!: number; + + @Expose({ name: 'pages_total' }) + pagesTotal!: number; +} + +export default MetadataModel; diff --git a/src/models/Token.ts b/src/models/Token.ts new file mode 100644 index 00000000..d4f05fbe --- /dev/null +++ b/src/models/Token.ts @@ -0,0 +1,14 @@ +import { Exclude, Expose, Transform, Type } from 'class-transformer'; + +@Exclude() +class TokenModel { + @Expose({ name: 'current_price' }) + price!: number; + + @Expose({ name: 'symbol' }) + @Type(() => String) + @Transform(({ value }) => value.toString().toLowerCase()) + denom!: string; +} + +export default TokenModel; diff --git a/src/models/index.ts b/src/models/index.ts index 5394b612..f62d8547 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -2,23 +2,31 @@ import { LumTypes, LumWallet } from '@lum-network/sdk-javascript'; import { Proposal as BaseProposal } from '@lum-network/sdk-javascript/build/codec/cosmos/gov/v1/gov'; import { Validator } from '@lum-network/sdk-javascript/build/codec/cosmos/staking/v1beta1/staking'; import { Models } from '@rematch/core'; + import { governance } from '../redux/models/governance'; import { staking } from '../redux/models/staking'; import { wallet } from '../redux/models/wallet'; +import { stats } from '../redux/models/stats'; export interface RootModel extends Models { wallet: typeof wallet; staking: typeof staking; governance: typeof governance; + stats: typeof stats; } -export const reduxModels: RootModel = { wallet, staking, governance }; +export const reduxModels: RootModel = { wallet, staking, governance, stats }; export interface Wallet extends LumWallet { isExtensionImport?: boolean; isNanoS?: boolean; } +export interface OtherBalance { + denom: string; + amount: number; +} + export interface CommonTransactionProps { messages: string[]; hash: string; @@ -119,3 +127,6 @@ export interface LumInfo { name: number; previousDaysPrices: PreviousDayPrice[]; } + +export { default as MetadataModel } from './Metadata'; +export { default as TokenModel } from './Token'; diff --git a/src/redux/models/stats.ts b/src/redux/models/stats.ts new file mode 100644 index 00000000..0ef75359 --- /dev/null +++ b/src/redux/models/stats.ts @@ -0,0 +1,29 @@ +import { createModel } from '@rematch/core'; +import { RootModel, TokenModel } from 'models'; +import { StatsApi } from 'utils'; + +interface StatsState { + prices: TokenModel[]; +} + +export const stats = createModel()({ + name: 'stats', + state: { + prices: [], + } as StatsState, + reducers: { + setPrices(state, prices: TokenModel[]) { + return { + ...state, + prices, + }; + }, + }, + effects: (dispatch) => ({ + async getPrices() { + const [res] = await StatsApi.getPrices(); + + dispatch.stats.setPrices(res); + }, + }), +}); diff --git a/src/redux/models/wallet.ts b/src/redux/models/wallet.ts index c3e2823e..8cdd8e67 100644 --- a/src/redux/models/wallet.ts +++ b/src/redux/models/wallet.ts @@ -3,17 +3,36 @@ import { createModel } from '@rematch/core'; import { Window as KeplrWindow } from '@keplr-wallet/types'; import { LumUtils, LumWalletFactory, LumWallet, LumConstants } from '@lum-network/sdk-javascript'; import { VoteOption } from '@lum-network/sdk-javascript/build/codec/cosmos/gov/v1beta1/gov'; +import { DelegationDelegatorReward } from '@lum-network/sdk-javascript/build/codec/cosmos/distribution/v1beta1/distribution'; import TransportWebUsb from '@ledgerhq/hw-transport-webusb'; import { DeviceModelId } from '@ledgerhq/devices'; -import { LUM_COINGECKO_ID } from 'constant'; +import { CLIENT_PRECISION, LUM_COINGECKO_ID } from 'constant'; import i18n from 'locales'; -import { getRpcFromNode, getWalletLink, GuardaUtils, showErrorToast, showSuccessToast, WalletClient } from 'utils'; - -import { Airdrop, HardwareMethod, Proposal, Rewards, RootModel, Transaction, Vestings, Wallet } from '../../models'; +import { + getRpcFromNode, + getWalletLink, + GuardaUtils, + NumbersUtils, + showErrorToast, + showSuccessToast, + WalletClient, +} from 'utils'; import { LOGOUT } from 'redux/constants'; +import { + Airdrop, + HardwareMethod, + OtherBalance, + Proposal, + Rewards, + RootModel, + Transaction, + Vestings, + Wallet, +} from '../../models'; + interface SendPayload { to: string; from: Wallet; @@ -34,7 +53,7 @@ interface GetRewardPayload { memo: string; } -interface GetAllRewardsPayload { +interface GetRewardsFromValidatorsPayload { validatorsAddresses: string[]; from: Wallet; memo: string; @@ -65,7 +84,9 @@ interface SetWalletDataPayload { fiat: number; lum: number; }; + otherBalances?: OtherBalance[]; rewards?: Rewards; + otherRewards?: Rewards[]; vestings?: Vestings; airdrop?: Airdrop; } @@ -82,8 +103,10 @@ interface WalletState { fiat: number; lum: number; }; + otherBalances: OtherBalance[]; transactions: Transaction[]; rewards: Rewards; + otherRewards: Rewards[]; vestings: Vestings | null; airdrop: Airdrop | null; currentNode: string; @@ -97,11 +120,13 @@ export const wallet = createModel()({ fiat: 0, lum: 0, }, + otherBalances: [], transactions: [], rewards: { rewards: [], total: [], }, + otherRewards: [], vestings: null, airdrop: null, currentNode: process.env.REACT_APP_RPC_URL || '', @@ -127,7 +152,9 @@ export const wallet = createModel()({ return { ...state, rewards: data.rewards || state.rewards, + otherRewards: data.otherRewards || state.otherRewards, currentBalance: data.currentBalance || state.currentBalance, + otherBalances: data.otherBalances || state.otherBalances, transactions: data.transactions || state.transactions, vestings: data.vestings || state.vestings, airdrop: data.airdrop || state.airdrop, @@ -145,8 +172,8 @@ export const wallet = createModel()({ const result = await WalletClient.getWalletBalance(address); if (result) { - const { currentBalance } = result; - dispatch.wallet.setWalletData({ currentBalance }); + const { currentBalance, otherBalances } = result; + dispatch.wallet.setWalletData({ currentBalance, otherBalances }); } }, async getTransactions(address: string) { @@ -157,10 +184,101 @@ export const wallet = createModel()({ } }, async getRewards(address: string) { - const rewards = await WalletClient.getRewards(address); + const res = await WalletClient.getRewards(address); + + if (res) { + const { rewards: r } = res; + + const oRewards: DelegationDelegatorReward[] = []; + const lumRewards: DelegationDelegatorReward[] = []; + + for (const delegatorReward of r) { + const otherReward = delegatorReward.reward.filter( + (reward) => reward.denom !== LumConstants.MicroLumDenom, + ); + + const lumReward = delegatorReward.reward.filter( + (reward) => reward.denom === LumConstants.MicroLumDenom, + ); + + if (otherReward.length > 0) { + oRewards.push({ + validatorAddress: delegatorReward.validatorAddress, + reward: otherReward, + }); + } + + if (lumReward.length > 0) { + lumRewards.push({ + validatorAddress: delegatorReward.validatorAddress, + reward: lumReward, + }); + } + } - if (rewards) { - dispatch.wallet.setWalletData({ rewards }); + const rewards = { + rewards: lumRewards, + total: [ + { + denom: LumConstants.MicroLumDenom, + amount: NumbersUtils.convertUnitNumber( + lumRewards.reduce( + (acc, r) => + NumbersUtils.convertUnitNumber(r.reward.length > 0 ? r.reward[0].amount : '0') / + CLIENT_PRECISION + + acc, + 0, + ), + LumConstants.LumDenom, + LumConstants.MicroLumDenom, + ).toFixed(), + }, + ], + }; + + const otherRewards: Rewards[] = []; + + for (const oR of oRewards) { + const existsInArrayIndex = otherRewards.findIndex( + (item) => + item.rewards.length > 0 && + item.rewards[0].reward.length > 0 && + item.rewards[0].reward[0].denom === (oR.reward[0].denom || ''), + ); + + if (oR.reward.length > 0) { + if (existsInArrayIndex > -1) { + const oldTotal = NumbersUtils.convertUnitNumber( + otherRewards[existsInArrayIndex].total[0].amount, + ); + + const rewardAmount = NumbersUtils.convertUnitNumber( + parseFloat(oR.reward[0].amount) / CLIENT_PRECISION, + ); + + otherRewards[existsInArrayIndex].rewards.push({ + ...oR, + }); + + otherRewards[existsInArrayIndex].total[0].amount = NumbersUtils.convertUnitNumber( + oldTotal + rewardAmount, + LumConstants.LumDenom, + LumConstants.MicroLumDenom, + ).toFixed(); + } else { + otherRewards.push({ + rewards: [oR], + total: [ + { + denom: oR.reward[0].denom, + amount: (parseFloat(oR.reward[0].amount) / CLIENT_PRECISION).toFixed(), + }, + ], + }); + } + } + } + dispatch.wallet.setWalletData({ rewards, otherRewards }); } }, async getVestings(address: string) { @@ -179,6 +297,7 @@ export const wallet = createModel()({ }, async reloadWalletInfos(address: string) { await Promise.all([ + dispatch.stats.getPrices(), dispatch.wallet.getWalletBalance(address), dispatch.wallet.getTransactions(address), dispatch.wallet.getRewards(address), @@ -423,8 +542,12 @@ export const wallet = createModel()({ dispatch.wallet.reloadWalletInfos(payload.from.getAddress()); return result; }, - async getAllRewards(payload: GetAllRewardsPayload) { - const result = await WalletClient.getAllRewards(payload.from, payload.validatorsAddresses, payload.memo); + async getRewardsFromValidators(payload: GetRewardsFromValidatorsPayload) { + const result = await WalletClient.getRewardsFromValidators( + payload.from, + payload.validatorsAddresses, + payload.memo, + ); if (!result) { return null; diff --git a/src/screens/Dashboard/Dashboard.tsx b/src/screens/Dashboard/Dashboard.tsx index ac519ea9..d860a4ce 100644 --- a/src/screens/Dashboard/Dashboard.tsx +++ b/src/screens/Dashboard/Dashboard.tsx @@ -5,7 +5,15 @@ import { LumConstants, LumUtils } from '@lum-network/sdk-javascript'; import { Card } from 'frontend-elements'; import { LUM_TWITTER } from 'constant'; -import { TransactionsTable, AddressCard, AvailableCard, LumPriceCard, BalanceCard, AirdropCard } from 'components'; +import { + TransactionsTable, + AddressCard, + AvailableCard, + LumPriceCard, + BalanceCard, + AirdropCard, + OtherAssetsTable, +} from 'components'; import { RootState } from 'redux/store'; import StakedCoinsCard from '../Staking/components/Cards/StakedCoinsCard'; @@ -15,11 +23,12 @@ import './styles/Dashboard.scss'; const Dashboard = (): JSX.Element => { // Redux hooks - const { transactions, balance, wallet, vestings, airdrop, stakedCoins, rewards } = useSelector( + const { transactions, balance, otherBalances, wallet, vestings, airdrop, stakedCoins, rewards } = useSelector( (state: RootState) => ({ loading: state.loading.global.loading, transactions: state.wallet.transactions, balance: state.wallet.currentBalance, + otherBalances: state.wallet.otherBalances, wallet: state.wallet.currentWallet, stakedCoins: state.staking.stakedCoins, vestings: state.wallet.vestings, @@ -95,6 +104,18 @@ const Dashboard = (): JSX.Element => { + {otherBalances.length > 0 && ( +
+
+ +
+

{t('dashboard.otherBalancesTable.title')}

+
+ +
+
+
+ )}
diff --git a/src/screens/Operations/components/Forms/GetAllRewards.tsx b/src/screens/Operations/components/Forms/GetAllRewards.tsx index d2aff372..3eb069e0 100644 --- a/src/screens/Operations/components/Forms/GetAllRewards.tsx +++ b/src/screens/Operations/components/Forms/GetAllRewards.tsx @@ -1,5 +1,4 @@ import { Input, Button as CustomButton } from 'components'; -import { CLIENT_PRECISION } from 'constant'; import { FormikContextType } from 'formik'; import { Button } from 'frontend-elements'; import { Rewards } from 'models'; @@ -28,7 +27,7 @@ const GetAllRewards = ({ form, isLoading, rewards }: Props): JSX.Element => { 0 - ? NumbersUtils.convertUnitNumber(rewards.total[0].amount) / CLIENT_PRECISION + ? NumbersUtils.convertUnitNumber(rewards.total[0].amount) : 0, )} readOnly diff --git a/src/screens/Staking/Staking.tsx b/src/screens/Staking/Staking.tsx index c0cb92c7..8ebbe9fc 100644 --- a/src/screens/Staking/Staking.tsx +++ b/src/screens/Staking/Staking.tsx @@ -32,10 +32,12 @@ import Delegate from '../Operations/components/Forms/Delegate'; import Undelegate from '../Operations/components/Forms/Undelegate'; import GetReward from '../Operations/components/Forms/GetReward'; import GetAllRewards from '../Operations/components/Forms/GetAllRewards'; -import Redelegate from 'screens/Operations/components/Forms/Redelegate'; +import Redelegate from '../Operations/components/Forms/Redelegate'; +import OtherStakingRewards from './components/Lists/OtherStakingRewards'; -// eslint-disable-next-line @typescript-eslint/no-empty-function -const noop = () => {}; +const noop = () => { + //do nothing +}; const Staking = (): JSX.Element => { // State @@ -44,20 +46,27 @@ const Staking = (): JSX.Element => { const [confirming, setConfirming] = useState(false); const [operationModal, setOperationModal] = useState(null); const [topValidatorConfirmationModal, setTopValidatorConfirmationModal] = useState(null); - const [onConfirmOperation, setOnConfirmOperation] = useState<() => void>(() => noop); + const [onConfirmOperation, setOnConfirmOperation] = useState<() => void>(noop); const [totalVotingPower, setTotalVotingPower] = useState(0); // Dispatch methods - const { getValidatorsInfos, delegate, redelegate, undelegate, getWalletInfos, getAllRewards, getReward } = - useRematchDispatch((dispatch: RootDispatch) => ({ - getValidatorsInfos: dispatch.staking.getValidatorsInfosAsync, - delegate: dispatch.wallet.delegate, - redelegate: dispatch.wallet.redelegate, - undelegate: dispatch.wallet.undelegate, - getWalletInfos: dispatch.wallet.reloadWalletInfos, - getReward: dispatch.wallet.getReward, - getAllRewards: dispatch.wallet.getAllRewards, - })); + const { + getValidatorsInfos, + delegate, + redelegate, + undelegate, + getWalletInfos, + getRewardsFromValidators, + getReward, + } = useRematchDispatch((dispatch: RootDispatch) => ({ + getValidatorsInfos: dispatch.staking.getValidatorsInfosAsync, + delegate: dispatch.wallet.delegate, + redelegate: dispatch.wallet.redelegate, + undelegate: dispatch.wallet.undelegate, + getWalletInfos: dispatch.wallet.reloadWalletInfos, + getReward: dispatch.wallet.getReward, + getRewardsFromValidators: dispatch.wallet.getRewardsFromValidators, + })); // Redux state values const { @@ -70,6 +79,7 @@ const Staking = (): JSX.Element => { wallet, vestings, rewards, + otherRewards, balance, delegations, unbondings, @@ -84,6 +94,7 @@ const Staking = (): JSX.Element => { vestings: state.wallet.vestings, balance: state.wallet.currentBalance, rewards: state.wallet.rewards, + otherRewards: state.wallet.otherRewards, bondedValidators: state.staking.validators.bonded, unbondedValidators: state.staking.validators.unbonded, unbondingValidators: state.staking.validators.unbonding, @@ -95,7 +106,7 @@ const Staking = (): JSX.Element => { loadingRedelegate: state.loading.effects.wallet.redelegate.loading, loadingUndelegate: state.loading.effects.wallet.undelegate.loading, loadingClaim: state.loading.effects.wallet.getReward.loading, - loadingClaimAll: state.loading.effects.wallet.getAllRewards.loading, + loadingClaimAll: state.loading.effects.wallet.getRewardsFromValidators.loading, })); const loadingAll = loadingDelegate || loadingUndelegate; @@ -318,7 +329,7 @@ const Staking = (): JSX.Element => { }) .map((val) => val.operatorAddress); - const getAllRewardsResult = await getAllRewards({ + const getAllRewardsResult = await getRewardsFromValidators({ from: wallet, validatorsAddresses, memo, @@ -416,7 +427,7 @@ const Staking = (): JSX.Element => { return ( <> -
+
{airdrop && airdrop.amount > 0 ? ( @@ -465,6 +476,20 @@ const Staking = (): JSX.Element => { )}
+ {otherRewards.length > 0 && ( +
+ + + +
+ )}
{ className="logout-modal-cancel-btn me-sm-4 mb-4 mb-sm-0" data-bs-dismiss="modal" onClick={() => { - setOnConfirmOperation(() => noop); + setOnConfirmOperation(noop); }} >
{t('common.cancel')}
diff --git a/src/screens/Staking/components/Cards/RewardsCard.tsx b/src/screens/Staking/components/Cards/RewardsCard.tsx index e87243b8..16443cb4 100644 --- a/src/screens/Staking/components/Cards/RewardsCard.tsx +++ b/src/screens/Staking/components/Cards/RewardsCard.tsx @@ -1,7 +1,6 @@ import React from 'react'; import assets from 'assets'; -import { CLIENT_PRECISION } from 'constant'; import { Button, Card } from 'frontend-elements'; import { Rewards } from 'models'; import { useTranslation } from 'react-i18next'; @@ -27,7 +26,7 @@ const RewardsCard = ({ rewards, onClaim, isLoading }: Props): JSX.Element => { 0 - ? NumbersUtils.convertUnitNumber(rewards.total[0].amount) / CLIENT_PRECISION + ? NumbersUtils.convertUnitNumber(rewards.total[0].amount) : 0, )} big diff --git a/src/screens/Staking/components/Lists/OtherStakingRewards.tsx b/src/screens/Staking/components/Lists/OtherStakingRewards.tsx new file mode 100644 index 00000000..f6c4be7b --- /dev/null +++ b/src/screens/Staking/components/Lists/OtherStakingRewards.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import numeral from 'numeral'; +import { Validator } from '@lum-network/sdk-javascript/build/codec/cosmos/staking/v1beta1/staking'; + +import { Collapsible, SmallerDecimal } from 'components'; +import { CLIENT_PRECISION, LUM_ASSETS_GITHUB } from 'constant'; +import { Card, ValidatorLogo } from 'frontend-elements'; +import { Reward, Rewards } from 'models'; +import { RootState } from 'redux/store'; +import { DenomsUtils, NumbersUtils, WalletClient, getExplorerLink, trunc, useWindowSize } from 'utils'; + +const OtherStakingRewards = ({ validators, otherRewards }: { validators: Validator[]; otherRewards: Rewards[] }) => { + const prices = useSelector((state: RootState) => state.stats.prices); + + const { t } = useTranslation(); + + const winSizes = useWindowSize(); + + const renderRow = (rewards: Reward, index: number) => { + const normalDenom = DenomsUtils.computeDenom(rewards.reward[0].denom); + const price = prices.find((p) => p.denom === normalDenom); + const amount = NumbersUtils.convertUnitNumber(parseFloat(rewards.reward[0].amount) / CLIENT_PRECISION); + const validator = validators.find((val) => val.operatorAddress === rewards.validatorAddress); + + return ( +
+ + + + {validator?.description?.moniker || + validator?.description?.identity || + trunc(rewards.validatorAddress)} + + +
+
+ + {DenomsUtils.computeDenom(rewards.reward[0].denom).toUpperCase()} +
+
{price && numeral(amount * price.price).format('$0,0[.]00')}
+
+
+ ); + }; + + const renderCollapsible = (rewards: Rewards, index: number) => { + const normalDenom = DenomsUtils.computeDenom(rewards.total[0].denom); + const price = prices.find((p) => p.denom === normalDenom); + const amount = NumbersUtils.convertUnitNumber(rewards.total[0].amount); + + return ( + + 575.98} + header={ +
+ {winSizes.width > 575.98 && ( + + )} +
+
+ + + {DenomsUtils.computeDenom(rewards.total[0].denom).toUpperCase()} + +
+
+ {price && numeral(amount * price.price).format('$0,0[.]00')} +
+
+
+ } + content={<>{rewards.rewards.map(renderRow)}} + /> +
+ ); + }; + + return ( + <> +
+

{t('staking.otherStakingRewards.title')}

+
+ {otherRewards.map(renderCollapsible)} + + ); +}; + +export default OtherStakingRewards; diff --git a/src/screens/Staking/components/Lists/styles/Lists.scss b/src/screens/Staking/components/Lists/styles/Lists.scss index 28c44a71..035b7387 100644 --- a/src/screens/Staking/components/Lists/styles/Lists.scss +++ b/src/screens/Staking/components/Lists/styles/Lists.scss @@ -1,82 +1,128 @@ @import 'src/styles/main'; -.delegate-btn, -.delegate-btn:hover { - display: flex; - justify-content: center; - align-items: center; - padding: 5px 15px; - position: relative; - background: linear-gradient(white, white) padding-box, - linear-gradient(to top, rgba(192, 113, 79, 1), rgba(253, 184, 134, 1)) border-box; - border: 1px solid transparent; - color: $color-primary; -} - -tr:nth-child(odd) > td > .delegate-btn { - background: linear-gradient($color-striped, $color-striped) padding-box, - linear-gradient(to top, rgba(192, 113, 79, 1), rgba(253, 184, 134, 1)) border-box; -} - -.validators-table { - overflow-y: visible !important; -} +#staking { + .delegate-btn, + .delegate-btn:hover { + display: flex; + justify-content: center; + align-items: center; + padding: 5px 15px; + position: relative; + background: linear-gradient(white, white) padding-box, + linear-gradient(to top, rgba(192, 113, 79, 1), rgba(253, 184, 134, 1)) border-box; + border: 1px solid transparent; + color: $color-primary; + } -.validators-search-input { - width: 264px; -} + tr:nth-child(odd) > td > .delegate-btn { + background: linear-gradient($color-striped, $color-striped) padding-box, + linear-gradient(to top, rgba(192, 113, 79, 1), rgba(253, 184, 134, 1)) border-box; + } -.search-input { - box-shadow: inset 0px 3.42928px 5.14392px rgba(0, 0, 0, 0.05); -} + .validators-table { + overflow-y: visible !important; + } -.search-input > input { - font-size: 10px; - margin-left: 0.5rem !important; -} + .validators-search-input { + width: 264px; + } -@include media-breakpoint-down(lg) { - .validators-table-row > td { - padding-top: 1.5rem !important; - padding-bottom: 1.5rem !important; + .search-input { + box-shadow: inset 0px 3.42928px 5.14392px rgba(0, 0, 0, 0.05); } - .validators-table > table.table thead { - display: none; + + .search-input > input { + font-size: 10px; + margin-left: 0.5rem !important; } - .validators-table > table.table tr { - display: block; - margin: 12px 0; + .action-btn { + background-color: transparent; + border: 2px $color-primary solid; + color: $color-primary; + padding: 5px 15px; + &:disabled { + background-color: transparent; + color: $color-grey; + border: 2px $color-grey solid; + } } - .validators-table > table.table td { - display: block; - text-align: right; - height: 40px; - padding-left: 24px !important; - padding-right: 24px !important; + .other-rewards-card { + box-shadow: none; + background-color: $color-striped; + + .other-reward-collapse { + border-top: rgba(0, 0, 0, 0.2) 1px solid; + } } +} + +@include media-breakpoint-down(lg) { + #staking { + .validators-table-row > td { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .validators-table > table.table thead { + display: none; + } - .validators-table > table.table td:before { - content: attr(data-label); - float: left; - font-weight: $medium; + .validators-table > table.table tr { + display: block; + margin: 12px 0; + } + + .validators-table > table.table td { + display: block; + text-align: right; + height: 40px; + padding-left: 24px !important; + padding-right: 24px !important; + } + + .validators-table > table.table td:before { + content: attr(data-label); + float: left; + font-weight: $medium; + } } } @media (prefers-color-scheme: dark) { - .validator-logo { - filter: brightness(0) invert(1); - } - .delegate-btn, - .delegate-btn:hover { - background: linear-gradient($color-primary, $color-primary) padding-box, - linear-gradient(to top, rgba(192, 113, 79, 1), rgba(253, 184, 134, 1)) border-box; - color: white; - } + #staking { + .validator-logo { + filter: brightness(0) invert(1); + } + .delegate-btn, + .delegate-btn:hover { + background: linear-gradient($color-primary, $color-primary) padding-box, + linear-gradient(to top, rgba(192, 113, 79, 1), rgba(253, 184, 134, 1)) border-box; + color: white; + } - tr:nth-child(odd) > td > .delegate-btn { - background: linear-gradient($color-dark-striped, $color-dark-striped) padding-box, - linear-gradient(to top, rgba(192, 113, 79, 1), rgba(253, 184, 134, 1)) border-box; + tr:nth-child(odd) > td > .delegate-btn { + background: linear-gradient($color-dark-striped, $color-dark-striped) padding-box, + linear-gradient(to top, rgba(192, 113, 79, 1), rgba(253, 184, 134, 1)) border-box; + } + + .action-btn { + border: 2px white solid; + color: white; + + &:disabled { + background-color: transparent; + color: $color-grey; + border: 2px $color-grey solid; + } + } + + .other-rewards-card { + background-color: $color-dark-striped; + + .other-reward-collapse { + border-top: rgba(255, 255, 255, 0.2) 1px solid; + } + } } } diff --git a/src/tests/app.test.tsx b/src/tests/app.test.tsx index 2ac24c49..1f48f3ce 100644 --- a/src/tests/app.test.tsx +++ b/src/tests/app.test.tsx @@ -1,3 +1,4 @@ +import 'reflect-metadata'; import React from 'react'; import { screen, within, render } from '@testing-library/react'; import i18n from 'locales'; diff --git a/src/utils/client.ts b/src/utils/client.ts index 56a6bb37..f3793b6a 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -4,7 +4,7 @@ import { ProposalStatus, VoteOption } from '@lum-network/sdk-javascript/build/co import { Window as KeplrWindow } from '@keplr-wallet/types'; import { PasswordStrengthType, PasswordStrength, Wallet, Proposal, LumInfo } from 'models'; -import { showErrorToast, showSuccessToast } from 'utils'; +import { DenomsUtils, NumbersUtils, showErrorToast, showSuccessToast } from 'utils'; import i18n from 'locales'; import { COINGECKO_API_URL, IPFS_GATEWAY, MessageTypes } from 'constant'; @@ -267,13 +267,24 @@ class WalletClient { let lum = 0; let fiat = 0; + const otherBalancesArr = []; + const balances = await this.lumClient.getAllBalances(address); + const ulumBalance = balances.find((balance) => balance.denom === LumConstants.MicroLumDenom); + const otherBalances = balances.filter((balance) => balance.denom !== LumConstants.MicroLumDenom); if (ulumBalance) { lum += Number(LumUtils.convertUnit(ulumBalance, LumConstants.LumDenom)); } + for (const oB of otherBalances) { + otherBalancesArr.push({ + denom: DenomsUtils.computeDenom(oB.denom), + amount: NumbersUtils.convertUnitNumber(oB.amount), + }); + } + if (this.lumInfos) { fiat = lum * this.lumInfos.price; } @@ -283,6 +294,7 @@ class WalletClient { lum, fiat, }, + otherBalances: otherBalancesArr, }; }; @@ -674,7 +686,7 @@ class WalletClient { }; }; - getAllRewards = async (fromWallet: Wallet, validatorsAddresses: string[], memo: string) => { + getRewardsFromValidators = async (fromWallet: Wallet, validatorsAddresses: string[], memo: string) => { if (this.lumClient === null) { return null; } diff --git a/src/utils/denoms.ts b/src/utils/denoms.ts index de24d9d2..64a022b3 100644 --- a/src/utils/denoms.ts +++ b/src/utils/denoms.ts @@ -1,12 +1,31 @@ +import assets from 'assets'; import { IBCDenoms } from 'constant'; export const computeDenom = (denom: string): string => { switch (denom) { case IBCDenoms.USDC: return 'usdc'; + case IBCDenoms.ATOM_TESTNET: + case IBCDenoms.ATOM: + return 'atom'; + case IBCDenoms.OSMO: + return 'osmo'; case 'udfr': return 'dfr'; default: return 'lum'; } }; + +export const getIconFromDenom = (denom: string) => { + switch (denom) { + case 'atom': + return assets.images.tokens.atom; + case 'osmo': + return assets.images.tokens.osmo; + case 'dfr': + return assets.images.tokens.dfr; + case 'usdc': + return assets.images.tokens.usdc; + } +}; diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index c358535c..e4fc28dc 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; export const usePrevious = (value: T): T | undefined => { const ref = useRef(); @@ -7,3 +7,32 @@ export const usePrevious = (value: T): T | undefined => { }, [value]); return ref.current; }; + +interface WindowSize { + width: number; + height: number; +} + +export const useWindowSize = (): WindowSize => { + const [windowSize, setWindowSize] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }); + useEffect(() => { + // Handler to call on window resize + function handleResize() { + // Set window width/height to state + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + } + // Add event listener + window.addEventListener('resize', handleResize); + // Call handler right away so state gets updated with initial window size + handleResize(); + // Remove event listener on cleanup + return () => window.removeEventListener('resize', handleResize); + }, []); + return windowSize; +}; diff --git a/src/utils/http.ts b/src/utils/http.ts new file mode 100644 index 00000000..c0c5426e --- /dev/null +++ b/src/utils/http.ts @@ -0,0 +1,40 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import { plainToInstance } from 'class-transformer'; +import { MetadataModel } from 'models'; + +declare module 'axios' { + interface AxiosResponse extends Promise { + data: T; + } +} + +abstract class HttpClient { + protected readonly instance: AxiosInstance; + private readonly subObject?: string; + + protected constructor(baseURL: string, subObject?: string) { + this.instance = axios.create({ baseURL, timeout: 10000 }); + this.subObject = subObject; + + this.initializeResponseInterceptor(); + } + + private initializeResponseInterceptor = () => { + this.instance.interceptors.response.use((res) => this.handleResponse(res), this.handleError); + }; + + private handleResponse = ({ data }: AxiosResponse) => data; + + private handleError = (error: any): Promise => Promise.reject(error); + + protected request = async (config: AxiosRequestConfig, Model: any): Promise<[T, MetadataModel]> => { + const response = await this.instance.request(config); + + return [ + plainToInstance(Model, this.subObject ? response[this.subObject] : response) as unknown as T, + plainToInstance(MetadataModel, response.metadata), + ]; + }; +} + +export default HttpClient; diff --git a/src/utils/index.ts b/src/utils/index.ts index e55588ab..6ccc99a5 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,5 @@ +export { default as HttpClient } from './http'; +export { default as StatsApi } from './statsApi'; export { default as WalletClient } from './client'; export type { MnemonicLength } from './client'; export * as WalletUtils from './client'; diff --git a/src/utils/links.ts b/src/utils/links.ts index 5b7c5c18..8b67a5b7 100644 --- a/src/utils/links.ts +++ b/src/utils/links.ts @@ -1,5 +1,12 @@ import WalletClient from './client'; -import { LUM_EXPLORER, LUM_EXPLORER_TESTNET, LUM_WALLET, LUM_WALLET_TESTNET } from 'constant'; +import { + LUM_EXPLORER, + LUM_EXPLORER_TESTNET, + LUM_MILLIONS, + LUM_MILLIONS_TESTNET, + LUM_WALLET, + LUM_WALLET_TESTNET, +} from 'constant'; const CUSTOM_NODE_KEY = 'custom-nodes'; @@ -24,3 +31,4 @@ export const getRpcFromNode = (node: string): string => `https://${node}/rpc`; export const getExplorerLink = (): string => (WalletClient.isTestnet() ? LUM_EXPLORER_TESTNET : LUM_EXPLORER); export const getWalletLink = (): string => (WalletClient.isTestnet() ? LUM_WALLET_TESTNET : LUM_WALLET); +export const getMillionsLink = (): string => (WalletClient.isTestnet() ? LUM_MILLIONS_TESTNET : LUM_MILLIONS); diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts index 4dcef43b..ade634f3 100644 --- a/src/utils/numbers.ts +++ b/src/utils/numbers.ts @@ -1,23 +1,31 @@ import { LumConstants, LumTypes, LumUtils } from '@lum-network/sdk-javascript'; import numeral from 'numeral'; -export const convertUnitNumber = (nb: number | string): number => { +export const convertUnitNumber = ( + nb: number | string, + fromDenom = LumConstants.MicroLumDenom, + toDenom = LumConstants.LumDenom, +): number => { let amount: string; + if (!nb) { + return 0; + } + if (typeof nb === 'string') { const split = nb.split('.'); amount = split[0]; } else { - amount = nb.toFixed(); + amount = nb.toFixed(fromDenom.startsWith('u') ? 0 : 6); } const coin = { amount, - denom: LumConstants.MicroLumDenom, + denom: fromDenom, }; - return parseFloat(LumUtils.convertUnit(coin, LumConstants.LumDenom)); + return parseFloat(LumUtils.convertUnit(coin, toDenom)); }; export const formatUnit = (coin: LumTypes.Coin, moreDecimal?: boolean): string => { diff --git a/src/utils/staking.ts b/src/utils/staking.ts index e22f907f..d9349f0d 100644 --- a/src/utils/staking.ts +++ b/src/utils/staking.ts @@ -53,21 +53,31 @@ export const getUserValidators = ( const validators = []; for (const delegation of delegations) { - for (const reward of rewards.rewards) { - if (delegation.delegation && reward.validatorAddress === delegation.delegation.validatorAddress) { - const validator = validatorsList.find( - (bondedVal) => - delegation.delegation && bondedVal.operatorAddress === delegation.delegation.validatorAddress, - ); - - if (validator) { - validators.push({ - ...validator, - reward: parseFloat(reward.reward.length > 0 ? reward.reward[0].amount : '0') / CLIENT_PRECISION, - stakedCoins: NumbersUtils.formatTo6digit( - NumbersUtils.convertUnitNumber(delegation.balance?.amount || '0'), - ), - }); + const validator = validatorsList.find( + (bondedVal) => + delegation.delegation && bondedVal.operatorAddress === delegation.delegation.validatorAddress, + ); + if (validator) { + if (rewards.rewards.length === 0) { + validators.push({ + ...validator, + reward: 0, + stakedCoins: NumbersUtils.formatTo6digit( + NumbersUtils.convertUnitNumber(delegation.balance?.amount || '0'), + ), + }); + } else { + for (const reward of rewards.rewards) { + if (delegation.delegation && reward.validatorAddress === delegation.delegation.validatorAddress) { + validators.push({ + ...validator, + reward: + parseFloat(reward.reward.length > 0 ? reward.reward[0].amount : '0') / CLIENT_PRECISION, + stakedCoins: NumbersUtils.formatTo6digit( + NumbersUtils.convertUnitNumber(delegation.balance?.amount || '0'), + ), + }); + } } } } diff --git a/src/utils/statsApi.ts b/src/utils/statsApi.ts new file mode 100644 index 00000000..78e1d1f1 --- /dev/null +++ b/src/utils/statsApi.ts @@ -0,0 +1,30 @@ +import { HttpClient } from 'utils'; +import { COINGECKO_API_URL } from 'constant'; +import { TokenModel } from 'models'; + +class StatsApi extends HttpClient { + private static instance?: StatsApi; + + private constructor() { + super(COINGECKO_API_URL); + } + + public static getInstance(): StatsApi { + if (!this.instance) { + this.instance = new StatsApi(); + } + + return this.instance; + } + + getPrices = async () => + this.request( + { + url: '/coins/markets?vs_currency=usd&ids=lum-network%2C%20cosmos%2C%20osmosis&order=id_asc', + method: 'GET', + }, + TokenModel, + ); +} + +export default StatsApi.getInstance(); diff --git a/src/utils/transactions.ts b/src/utils/transactions.ts index 5294e3d7..bc32b8bd 100644 --- a/src/utils/transactions.ts +++ b/src/utils/transactions.ts @@ -37,6 +37,21 @@ type DFractInfos = { amount: LumTypes.Coin; }; +type MillionsDepositInfos = { + depositorAddress: string; + poolId: Long; + amount: LumTypes.Coin; + isSponsor: boolean; + winnerAddress: string; +}; + +type MillionsLeavePoolInfo = { + depositId: Long; + poolId: Long; + toAddress: string; + depositorAddress: string; +}; + export const isSendTxInfo = ( info: { fromAddress?: string; @@ -91,6 +106,36 @@ export const isDfractInfo = ( return !!(info && info.depositorAddress && info.amount); }; +export const isMillionsDepositInfo = ( + info: { + depositorAddress?: string; + poolId?: Long; + amount?: LumTypes.Coin; + isSponsor?: boolean; + winnerAddress?: string; + } | null, +): info is MillionsDepositInfos => { + return !!( + info && + info.depositorAddress && + info.amount && + info.poolId && + info.winnerAddress && + info.isSponsor !== undefined + ); +}; + +export const isMillionsLeavePoolInfo = ( + info: { + depositId?: Long; + poolId?: Long; + toAddress?: string; + depositorAddress?: string; + } | null, +): info is MillionsLeavePoolInfo => { + return !!(info && info.depositorAddress && info.depositId && info.poolId && info.toAddress); +}; + export const hashExists = (txs: Transaction[], hash: string): boolean => txs.findIndex((tx) => tx.hash === hash) > -1; export const formatTxs = (rawTxs: readonly TxResponse[] | TxResponse[]): Transaction[] => { @@ -125,10 +170,7 @@ export const formatTxs = (rawTxs: readonly TxResponse[] | TxResponse[]): Transac if (typeof txInfos === 'object') { tx.messages.push(msg.typeUrl); - if (isDfractInfo(txInfos)) { - tx.fromAddress = txInfos.depositorAddress; - tx.amount.push(txInfos.amount); - } else if (isSendTxInfo(txInfos)) { + if (isSendTxInfo(txInfos)) { tx.fromAddress = txInfos.fromAddress; tx.toAddress = txInfos.toAddress; tx.amount = txInfos.amount; @@ -148,6 +190,15 @@ export const formatTxs = (rawTxs: readonly TxResponse[] | TxResponse[]): Transac tx.fromAddress = txInfos.sender; tx.toAddress = txInfos.receiver; tx.amount.push(txInfos.token); + } else if (isMillionsDepositInfo(txInfos)) { + tx.fromAddress = txInfos.depositorAddress; + tx.toAddress = i18n.t('transactions.pool') + txInfos.poolId.toString(); + tx.amount.push(txInfos.amount); + } else if (isMillionsLeavePoolInfo(txInfos)) { + tx.fromAddress = i18n.t('transactions.pool') + txInfos.poolId.toString(); + } else if (isDfractInfo(txInfos)) { + tx.fromAddress = txInfos.depositorAddress; + tx.amount.push(txInfos.amount); } } } catch {} @@ -198,6 +249,31 @@ export const getTxTypeInfos = ( return { name: i18n.t('transactions.types.vote'), icon: assets.images.messageTypes.vote }; case LumMessages.MsgDepositDfractUrl: return { name: i18n.t('transactions.types.depositDfract'), icon: assets.images.messageTypes.depositDfract }; + case LumMessages.MsgMillionsDepositUrl: + return { + name: i18n.t('transactions.types.depositMillions'), + icon: assets.images.messageTypes.depositMillions, + }; + case LumMessages.MsgDepositRetryUrl: + return { + name: i18n.t('transactions.types.depositRetryMillions'), + icon: assets.images.messageTypes.depositMillions, + }; + case LumMessages.MsgClaimPrizeUrl: + return { + name: i18n.t('transactions.types.claimMillions'), + icon: assets.images.messageTypes.claimMillions, + }; + case LumMessages.MsgWithdrawDepositUrl: + return { + name: i18n.t('transactions.types.withdrawMillions'), + icon: assets.images.messageTypes.withdrawMillions, + }; + case LumMessages.MsgWithdrawDepositRetryUrl: + return { + name: i18n.t('transactions.types.withdrawRetryMillions'), + icon: assets.images.messageTypes.withdrawMillions, + }; case MessageTypes.IBC_TRANSFER: return { name: i18n.t('transactions.types.ibcTransfer'), icon: assets.images.messageTypes.beam }; case MessageTypes.IBC_TIMEOUT: diff --git a/yarn.lock b/yarn.lock index 8c5ec73c..39733ff4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4538,6 +4538,11 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40" integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA== +class-transformer@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.5.1.tgz#24147d5dffd2a6cea930a3250a677addf96ab336" + integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw== + clean-css@^5.2.2: version "5.3.2" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.2.tgz#70ecc7d4d4114921f5d298349ff86a31a9975224" @@ -9875,6 +9880,11 @@ redux@^4.0.5: dependencies: "@babel/runtime" "^7.9.2" +reflect-metadata@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" + integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== + regenerate-unicode-properties@^10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz#7c3192cab6dd24e21cb4461e5ddd7dd24fa8374c"