diff --git a/.eslintrc.json b/.eslintrc.json index cf49506..895e796 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -6,7 +6,8 @@ "extends": [ "standard", "eslint:recommended", - "plugin:react/recommended" + "plugin:react/recommended", + "prettier" ], "globals": { "Atomics": "readonly", @@ -28,10 +29,10 @@ } }, "rules": { - "indent": [ - 2, - 4 - ], + // "indent": [ + // 2, + // 4 + // ], "arrow-parens": [ 2, "always" diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..cc60188 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "tabWidth": 4, + "useTabs": false, + "singleQuote": true, + "semi": true, + "trailingComma": "all" +} diff --git a/src/components/TextField/index.js b/src/components/TextField/index.js index 68bfdd9..27ef2e2 100644 --- a/src/components/TextField/index.js +++ b/src/components/TextField/index.js @@ -30,7 +30,26 @@ const useStyles = makeStyles(() => ({ const TextField = (props) => { const onChange = (e) => { - props.onChange(e.target.value); + // Prevents cursor from jumping to the end of the input (see https://stackoverflow.com/a/62433499) + // input.type of 'text' for numeric inputs is required in order to enable setting of selectionStart and selectionEnd + const caret = e.currentTarget.selectionStart; + const element = e.target; + window.requestAnimationFrame(() => { + try { + element.selectionStart = caret; + element.selectionEnd = caret; + } catch (err) { + console.error(err); + } + }); + // manually handle numeric input. + if (props.type === 'number') { + const parsedNumber = +(e.target.value); + const nextVal = Number.isNaN(parsedNumber) ? (+props.value ? props.value : '0') : e.target.value; + props.onChange(nextVal); + } else { + props.onChange(e.target.value); + } }; return ( @@ -45,11 +64,13 @@ const TextField = (props) => { : ''} id={props.id} + // ensures that manually parsed numeric inputs are displayed as numbers + inputMode={props.type === 'number' ? 'numeric' : 'text'} margin="normal" multiline={props.multiline ? props.multiline : false} name={props.name} placeholder={props.placeholder} - type={props.type ? props.type : 'text'} + type={props.type && props.type !== 'number' ? props.type : 'text'} value={props.value} variant="outlined" onChange={onChange}/> diff --git a/src/config.js b/src/config.js index a9f141b..2607961 100644 --- a/src/config.js +++ b/src/config.js @@ -1,4 +1,122 @@ -export const config = { +const configs = + JSON.parse(localStorage.getItem('chain-registry') || 'null') || []; + +const globalConfigs = configs; + +window.configs = configs; + +const latestCommitPromise = fetch( + `https://api.github.com/repos/cosmos/chain-registry/commits/master`, +) + .then((r) => r.json()) + .then((r) => r.sha); + +const fetchCached = async (url, options) => { + const latestCommitHash = await latestCommitPromise; + const cache = sessionStorage.getItem(latestCommitHash + url); + if (cache) { + return Promise.resolve(JSON.parse(cache)); + } + return fetch(url, options) + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error(response.statusText); + }) + .then((json) => { + sessionStorage.setItem( + latestCommitHash + url, + JSON.stringify(json), + ); + return json; + }); +}; +fetchCached('https://api.github.com/repos/cosmos/chain-registry/contents').then( + async (data) => { + const configs = []; + for (const file of data) { + try { + if (file.name.startsWith('.') || file.type !== 'dir') { + continue; + } + const assetsConfig = await fetchCached( + `https://raw.githubusercontent.com/cosmos/chain-registry/master/${file.name}/assetlist.json`, + ); + const chainConfig = await fetchCached( + `https://raw.githubusercontent.com/cosmos/chain-registry/master/${file.name}/chain.json`, + ); + const stakingAsset = assetsConfig.assets[0]; + const sortApis = (apis, type) => { + const getValue = (api) => { + return ( + api.address.includes('zenchainlabs') + + api.address.includes('.com') + + api.address.includes('.io') + ); + }; + const result = apis.sort((a, b) => { + return getValue(b) - getValue(a); + }); + + const isIPAddress = Number.isInteger( + +new URL(result[0].address).hostname.replaceAll( + '.', + '', + ), + ); + if (result[0] && isIPAddress) { + result.unshift({ + ...result[0], + address: `https://${type}-${chainConfig.chain_name.toLowerCase()}.keplr.app`, + }); + } + return result; + }; + const decimals = ( + stakingAsset.denom_units.find( + (unit) => unit.denom === stakingAsset.display, + ) || {} + ).exponent; + const config = { + RPC_URL: sortApis([...chainConfig.apis.rpc], 'rpc')[0] + .address, + REST_URL: sortApis([...chainConfig.apis.rest], 'lcd')[0] + .address, + EXPLORER_URL: `https://www.mintscan.io/${chainConfig.chain_name}`, + NETWORK_NAME: chainConfig.pretty_name, + NETWORK_TYPE: 'mainnet', + CHAIN_ID: chainConfig.chain_id, + CHAIN_NAME: chainConfig.chain_name, + COIN_DENOM: stakingAsset.symbol, + COIN_MINIMAL_DENOM: stakingAsset.base, + COIN_DECIMALS: decimals === undefined ? 6 : decimals, + PREFIX: stakingAsset.display, + COIN_TYPE: 118, + COINGECKO_ID: 'juno-network', + DEFAULT_GAS: 250000, + GAS_PRICE_STEP_LOW: 0.005, + GAS_PRICE_STEP_AVERAGE: 0.025, + GAS_PRICE_STEP_HIGH: 0.08, + FEATURES: ['stargate', 'ibc-transfer'], + }; + configs.push(config); + } catch (e) { + console.error(e); + } + } + console.log(configs); + // localStorage.setItem('chain', prompt('Enter chain name: ' + configs.map((c) => c.CHAIN_NAME).join(', '))); + if (JSON.stringify(globalConfigs) !== JSON.stringify(configs)) { + localStorage.setItem('chain-registry', JSON.stringify(configs)); + window.location.reload(); + } + }, +); + +export const config = configs.find( + (c) => c.NETWORK_NAME === localStorage.getItem('chain'), +) || { RPC_URL: 'https://rpc.juno.omniflix.co', REST_URL: 'https://api.juno.omniflix.co', EXPLORER_URL: 'https://www.mintscan.io/juno', diff --git a/src/containers/Home/TokenDetails/index.js b/src/containers/Home/TokenDetails/index.js index 01272f3..c9b4089 100644 --- a/src/containers/Home/TokenDetails/index.js +++ b/src/containers/Home/TokenDetails/index.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import './index.css'; import * as PropTypes from 'prop-types'; import variables from '../../../utils/variables'; @@ -17,60 +17,264 @@ const TokenDetails = (props) => { const staked = props.delegations.reduce((accumulator, currentValue) => { return accumulator + Number(currentValue.balance.amount); }, 0); - const balance = props.balance && props.balance.length && props.balance.find((val) => val.denom === config.COIN_MINIMAL_DENOM); - const available = (balance && balance.amount && Number(balance.amount)); + const balance = + props.balance && + props.balance.length && + props.balance.find((val) => val.denom === config.COIN_MINIMAL_DENOM); + const available = balance && balance.amount && Number(balance.amount); let unStaked = 0; props.unBondingDelegations.map((delegation) => { - delegation.entries && delegation.entries.length && - delegation.entries.map((entry) => { - unStaked = unStaked + Number(entry.balance); + delegation.entries && + delegation.entries.length && + delegation.entries.map((entry) => { + unStaked = unStaked + Number(entry.balance); - return null; - }); + return null; + }); return null; }); - const rewards = props.rewards && props.rewards.total && props.rewards.total.length && - props.rewards.total[0] && props.rewards.total[0].amount - ? props.rewards.total[0].amount / 10 ** config.COIN_DECIMALS : 0; + const rewards = + props.rewards && + props.rewards.total && + props.rewards.total.length && + props.rewards.total[0] && + props.rewards.total[0].amount + ? props.rewards.total[0].amount / 10 ** config.COIN_DECIMALS + : 0; + + const [apr, setApr] = useState(0); + const [price, setPrice] = useState(0); + const [supply, setSupply] = useState(0); + const [comparativeMarketCap, setComparativeMarketCap] = useState(0); + useEffect(() => { + (async () => { + // bump version + const supply = await fetch( + `${config.REST_URL}/cosmos/bank/v1beta1/supply/${config.COIN_MINIMAL_DENOM}`, + ) + .then((r) => r.json()) + .then(({ amount }) => amount.amount) + .catch((e) => 0); + const { inflation } = await fetch( + `${config.REST_URL}/cosmos/mint/v1beta1/inflation`, + ) + .then((r) => r.json()) + .catch((e) => ({ + inflation: config.CHAIN_NAME === 'stargaze' ? 0.35 : 0, + })); + const { + pool: { bonded_tokens: bonded }, + } = await fetch(`${config.REST_URL}/cosmos/staking/v1beta1/pool`) + .then((r) => r.json()) + .catch((e) => ({ pool: { bonded_tokens: 0 } })); + setApr(((+supply * +inflation) / +bonded) * 100); + const price = await fetch( + `https://api-osmosis.imperator.co/tokens/v1/${config.COIN_DENOM.toUpperCase()}`, + ) + .then((r) => r.json()) + .then(([{ price }]) => price) + .catch((e) => 0); + setPrice(price); + setSupply(supply); + })(); + }, []); + + function aprToApy(apr, numPeriods) { + return (1 + apr / numPeriods) ** numPeriods - 1; + } + const blocksInAYear = (365 * 24 * 60 * 60) / 7.5; return (
+
+

Price

+
+ available tokens +

${price.toFixed(4)}

+
+
+
+

APR

+
+ available tokens +

{apr.toFixed(2)}%

+
+
+
+

APY

+
+ available tokens +

+ {new Intl.NumberFormat().format( + +(aprToApy(apr / 100, blocksInAYear) * 100).toFixed( + 2, + ), + )} + % +

+
+
+
+

Market Cap

+
+ available tokens +

+ $ + {new Intl.NumberFormat().format( + +(supply / 10 ** config.COIN_DECIMALS) * price, + )} +

+
+
+
+

Comparative Market Cap

+ + setComparativeMarketCap(+e.currentTarget.value) + } + /> +
+ available tokens +

+ $ + {new Intl.NumberFormat().format( + comparativeMarketCap / + +(supply / 10 ** config.COIN_DECIMALS), + )} +

+
+
+
+

Earnings Per Year

+
+ available tokens +

+ {new Intl.NumberFormat().format( + +( + +(staked / 10 ** config.COIN_DECIMALS) * + (apr / 100) + ), + )} + , $ + {new Intl.NumberFormat().format( + +( + +(staked / 10 ** config.COIN_DECIMALS) * + (apr / 100) + ) * price, + )} +

+
+
+
+

Earnings Per Year (APY)

+
+ available tokens +

+ {new Intl.NumberFormat().format( + +( + +(staked / 10 ** config.COIN_DECIMALS) * + aprToApy(apr / 100, blocksInAYear) + ), + )} + , $ + {new Intl.NumberFormat().format( + +( + +(staked / 10 ** config.COIN_DECIMALS) * + aprToApy(apr / 100, blocksInAYear) + ) * price, + )} +

+
+
+
+

Earnings Per Day

+
+ available tokens +

+ {new Intl.NumberFormat().format( + +( + +(staked / 10 ** config.COIN_DECIMALS) * + (apr / 100 / 365) + ), + )} + , $ + {new Intl.NumberFormat().format( + +( + +(staked / 10 ** config.COIN_DECIMALS) * + (apr / 100 / 365) + ) * price, + )} +

+
+
+

{variables[props.lang]['available_tokens']}

- available tokens -

{available / (10 ** config.COIN_DECIMALS)}

+ available tokens +

{available / 10 ** config.COIN_DECIMALS}

- +

{variables[props.lang]['staked_tokens']}

- total tokens -

{staked / (10 ** config.COIN_DECIMALS)}

+ total tokens +

+ {new Intl.NumberFormat().format( + staked / 10 ** config.COIN_DECIMALS, + )} +

- - - + + + +
+
+
+

Staked Value

+
+ total tokens +

+ $ + {new Intl.NumberFormat().format( + (staked / 10 ** config.COIN_DECIMALS) * price, + )} +

+
+
+
+
+

Available Value

+
+ total tokens +

+ $ + {new Intl.NumberFormat().format( + (available / 10 ** config.COIN_DECIMALS) * price, + )} +

+

{variables[props.lang].rewards}

- total tokens + total tokens

{rewards > 0 ? rewards.toFixed(4) : 0}

- +

{variables[props.lang]['un_staked_tokens']}

- unstaked tokens -

{unStaked / (10 ** config.COIN_DECIMALS)}

+ unstaked tokens +

{unStaked / 10 ** config.COIN_DECIMALS}

@@ -119,7 +323,8 @@ const stateToProps = (state) => { balance: state.accounts.balance.result, balanceInProgress: state.accounts.balance.inProgress, unBondingDelegations: state.accounts.unBondingDelegations.result, - unBondingDelegationsInProgress: state.accounts.unBondingDelegations.inProgress, + unBondingDelegationsInProgress: + state.accounts.unBondingDelegations.inProgress, rewards: state.accounts.rewards.result, }; }; diff --git a/src/containers/Home/index.js b/src/containers/Home/index.js index afcb6b4..1484da9 100644 --- a/src/containers/Home/index.js +++ b/src/containers/Home/index.js @@ -67,7 +67,6 @@ class Home extends Component { render () { const { active } = this.state; const filteredProposals = this.props.proposals && this.props.proposals.filter((item) => item.status === 2); - return ( <> diff --git a/src/containers/NavBar/index.js b/src/containers/NavBar/index.js index 13c03a9..700cebc 100644 --- a/src/containers/NavBar/index.js +++ b/src/containers/NavBar/index.js @@ -159,7 +159,10 @@ class NavBar extends Component { }); } - render () { + render() { + const configs = + JSON.parse(localStorage.getItem('chain-registry') || 'null') || + [config]; return (
@@ -172,7 +175,18 @@ class NavBar extends Component { {(localStorage.getItem('of_co_address') || this.props.address) &&
-

{config.NETWORK_NAME}

+

+ +

{this.props.address}

diff --git a/src/containers/Stake/DelegateDialog/TokensTextField.js b/src/containers/Stake/DelegateDialog/TokensTextField.js index 71a8c7d..d90901b 100644 --- a/src/containers/Stake/DelegateDialog/TokensTextField.js +++ b/src/containers/Stake/DelegateDialog/TokensTextField.js @@ -39,16 +39,19 @@ const TokensTextField = (props) => { const vestingTokens = (vesting - delegatedVesting) / (10 ** config.COIN_DECIMALS); + // ensures that decimals used are not overprecise. (e.g. `1.0000000000000001` juno) + const currentPrecision = props.value?.includes?.('.') ? props.value.split('.')[1].length : 0; + const isCorrectPrecision = currentPrecision <= config.COIN_DECIMALS; return ( <> parseFloat(availableTokens + vestingTokens) : props.name === 'Delegate' || props.name === 'Stake' ? props.value > parseFloat(availableTokens) : props.name === 'Undelegate' || props.name === 'Redelegate' - ? props.value > parseFloat(stakedTokens) : false} - errorText="Invalid Amount" + ? props.value > parseFloat(stakedTokens) : false) } + errorText={isCorrectPrecision ? "Invalid Amount" : `Overprecise. Remove ${currentPrecision - config.COIN_DECIMALS} decimal places.`} id="tokens-text-field" name="tokens" type="number" diff --git a/src/containers/Stake/DelegateDialog/index.js b/src/containers/Stake/DelegateDialog/index.js index f4ba3a9..4483d5f 100644 --- a/src/containers/Stake/DelegateDialog/index.js +++ b/src/containers/Stake/DelegateDialog/index.js @@ -115,7 +115,10 @@ const DelegateDialog = (props) => { } } - const disable = !props.validator || !props.amount || inProgress || + // ensures that overprecise zero values (e.g. `0.0000001` juno) are not submitted as decimals + const precisionAppliedValueIsZero = +(+props.amount).toFixed(config.COIN_DECIMALS) === 0; + + const disable = !props.validator || !props.amount || precisionAppliedValueIsZero || inProgress || ((props.name === 'Delegate' || props.name === 'Stake') && vestingTokens ? props.amount > parseFloat((available + vestingTokens) / (10 ** config.COIN_DECIMALS)) : props.name === 'Delegate' || props.name === 'Stake'