diff --git a/package.json b/package.json index 938249148..46cbbfa59 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "eslint": "eslint 'ts/**/*.{ts,tsx}'", "lint": "tslint --format stylish --project . 'ts/**/*.ts' 'ts/**/*.tsx'", "fix": "yarn lint --fix", - "pre_push": "yarn typecheck && yarn lint:prettier && yarn lint && yarn test", + "pre_push": "yarn lint:prettier && yarn lint && yarn test", "update:tools": "aws s3 sync --delete s3://docs-markdown/ mdx/tools/ --profile $(npm config get awscli_profile)", "dev": "npm run update:tools && node --max-old-space-size=16384 ./node_modules/webpack-dev-server/bin/webpack-dev-server.js --mode development --content-base public --https", "deploy_dogfood": "npm run update:tools && yarn index_docs --environment dogfood && npm run build:prod && aws s3 sync ./public/. s3://dogfood.0xproject.com --profile $(npm config get awscli_profile) --region us-east-1 --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers && ./cdn-cache-clear.sh dogfood", @@ -77,8 +77,8 @@ "find-versions": "^2.0.0", "flickity": "^2.2.2", "fuse.js": "^3.4.6", - "graphql": "^15.5.0", - "graphql-request": "^3.4.0", + "graphql": "^15.5.1", + "graphql-request": "^3.5.0", "is-mobile": "^0.2.2", "less": "^2.7.2", "lodash": "^4.17.11", @@ -89,6 +89,7 @@ "moment": "2.21.0", "moment-precise-range-plugin": "^1.3.0", "moment-timezone": "^0.5.33", + "nice-color-palettes": "^3.0.0", "numeral": "^2.0.6", "polished": "^1.9.2", "query-string": "^6.0.0", @@ -104,6 +105,7 @@ "react-highlight": "0xproject/react-highlight#react-peer-deps", "react-instantsearch-dom": "^5.7.0", "react-markdown": "^4.0.6", + "react-minimal-pie-chart": "^8.2.0", "react-popper": "^1.0.0-beta.6", "react-query": "^3.7.1", "react-redux": "^7.1.3", @@ -127,6 +129,7 @@ "styled-components": "^5.0.0", "thenby": "^1.2.3", "truffle-contract": "2.0.1", + "urql": "^2.0.4", "valid-url": "^1.0.9", "web3-provider-engine": "14.0.6", "xml-js": "^1.6.4" diff --git a/public/images/mail.png b/public/images/mail.png new file mode 100644 index 000000000..799024709 Binary files /dev/null and b/public/images/mail.png differ diff --git a/public/images/treasury_breakdown/treasury_breakdown_eve.png b/public/images/treasury_breakdown/treasury_breakdown_eve.png new file mode 100644 index 000000000..096a3cfc3 Binary files /dev/null and b/public/images/treasury_breakdown/treasury_breakdown_eve.png differ diff --git a/public/images/treasury_breakdown/treasury_breakdown_past.png b/public/images/treasury_breakdown/treasury_breakdown_past.png new file mode 100644 index 000000000..15984ba86 Binary files /dev/null and b/public/images/treasury_breakdown/treasury_breakdown_past.png differ diff --git a/ts/components/dialogs/connect_wallet_dialog.tsx b/ts/components/dialogs/connect_wallet_dialog.tsx index 9baa70270..336b4cdc4 100644 --- a/ts/components/dialogs/connect_wallet_dialog.tsx +++ b/ts/components/dialogs/connect_wallet_dialog.tsx @@ -232,6 +232,8 @@ export const ConnectWalletDialog = () => { const handleAccount = async (currentConnector: AbstractConnector, option: Option) => { let address: string = ''; + (window as any).heap.track('Wallet connected', { wallet: option.type }); + // console.log(currentConnector, option) try { await activate(currentConnector, undefined, true); setActivatingConnector(currentConnector); diff --git a/ts/components/governance/hero.tsx b/ts/components/governance/hero.tsx new file mode 100644 index 000000000..ad6c776c4 --- /dev/null +++ b/ts/components/governance/hero.tsx @@ -0,0 +1,326 @@ +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as React from 'react'; +import styled from 'styled-components'; +import { GOVERNOR_CONTRACT_ADDRESS } from 'ts/utils/configs'; +import { formatNumber } from 'ts/utils/format_number'; + +import { ERC20TokenContract } from '@0x/contract-wrappers'; + +import { BigNumber } from '@0x/utils'; +import { useSelector } from 'react-redux'; + +import { State } from 'ts/redux/reducer'; +import { backendClient } from 'ts/utils/backend_client'; + +import { ZeroExProvider } from '@0x/asset-buyer'; +import { colors } from 'ts/style/colors'; + +interface GovernanceHeroProps { + title: string | React.ReactNode; + numProposals?: number | null; + titleMobile: string | React.ReactNode; + description: string | React.ReactNode; + figure: React.ReactNode; + actions: React.ReactNode; + provider?: ZeroExProvider; + videoId?: string; + videoChannel?: string; + videoRatio?: string; + youtubeOptions?: any; + averageVotingPower?: number | undefined; + metrics?: { + zrxStaked: number; + currentEpochRewards: BigNumber; + nextEpochStartDate: Date; + }; +} + +interface WrapperProps {} + +interface InnerProps {} + +interface RowProps {} + +const Wrapper = styled.div` + width: 100%; + text-align: center; + max-width: 1450px; + margin: 0 auto; + @media (min-width: 768px) { + padding: 30px; + text-align: left; + } +`; + +const Inner = styled.div` + background-color: #f3f6f4; + background-image: url(/images/stakingGraphic.svg); + background-repeat: no-repeat; + background-position-x: right; + background-position-y: center; + @media (min-width: 768px) { + padding: 30px; + } +`; + +const Row = styled.div` + max-width: 1152px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: column; + @media (min-width: 768px) { + flex-direction: row; + & > * { + } + } +`; + +const Column = styled.div` + padding: 30px; + @media (min-width: 768px) { + padding: 60px 28px; + &:first-child { + padding-left: 0; + } + &:last-child { + padding-right: 0; + } + } +`; + +const Title = styled.h1` + font-size: 46px; + line-height: 1.2; + font-weight: 300; + margin-bottom: 20px; + display: none; + @media (min-width: 768px) { + font-size: 50px; + display: block; + } +`; + +const TitleMobile = styled(Title)` + display: block; + @media (min-width: 768px) { + display: none; + } +`; + +const Description = styled.h2` + font-size: 18px; + line-height: 1.45; + font-weight: 300; + margin-bottom: 30px; + color: ${colors.textDarkSecondary}; +`; + +const Actions = styled.div` + display: flex; + flex-direction: column; + & > * { + margin-right: 13px; + margin-bottom: 10px; + } + @media (min-width: 768px) { + flex-direction: row; + } +`; + +const MetricsWrapper = styled.div` + display: flex; + flex-direction: column; +`; + +const FiguresList = styled.ol` + display: flex; + flex-direction: column; + flex-wrap: wrap; + padding-top: 15px; +`; + +const Figure = styled.li` + display: flex; + flex-direction: column; + justify-content: space-between; + text-align: left; + padding: 10px; + margin-bottom: 15px; + max-width: 50%; + @media (min-width: 480px) { + padding: 20px; + } +`; + +const FigurePair = styled.div` + display: flex; + justify-content: space-between; +`; +const FigureHeader = styled.header` + display: flex; + justify-content: space-between; + align-items: baseline; +`; + +const FigureTitle = styled.span` + display: block; + font-size: 16px; + line-height: 1.35; + margin-bottom: 5px; +`; + +const FigureNumber = styled.span` + display: block; + font-feature-settings: 'tnum' on, 'lnum' on; + font-size: 20px; + line-height: 1.35; + @media (min-width: 768px) { + font-size: 34px; + } + @media (min-width: 991px) { + font-size: 44px; + } +`; + +export const GovernanceHero: React.FC = (props) => { + const { title, titleMobile, description, actions, numProposals, averageVotingPower } = props; + const providerState = useSelector((state: State) => state.providerState); + + const [totalTreasuryAmountUSD, setTotalTreasuryAmountUSD] = React.useState('-'); + const [totalTreasuryDistributedUSD, setTotalTreasuryDistributedUSD] = React.useState('-'); + + const parseTotalDistributed = (transferData: any) => { + let totalDistributed = 0; + if (transferData) { + transferData.forEach((tf: any) => { + if (tf.data.items) { + tf.data.items.forEach((item: any) => { + item.transfers.forEach((transfer: any) => { + if (transfer.transfer_type === 'OUT') { + const delta_quote = + parseInt(transfer.delta, 10) * + Math.pow(10, -transfer.contract_decimals) * + transfer.quote_rate; + + totalDistributed += delta_quote; + } + }); + }); + } + }); + } + + return totalDistributed; + }; + + React.useEffect(() => { + const zrxTokenContract = new ERC20TokenContract( + '0xe41d2489571d322189246dafa5ebde1f4699f498', + providerState.provider, + ); + const maticTokenContract = new ERC20TokenContract( + '0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0', + providerState.provider, + ); + + // tslint:disable-next-line:no-floating-promises + (async () => { + const [zrxBalance, maticBalance] = await Promise.all([ + zrxTokenContract.balanceOf(GOVERNOR_CONTRACT_ADDRESS.ZRX).callAsync(), + maticTokenContract.balanceOf(GOVERNOR_CONTRACT_ADDRESS.ZRX).callAsync(), + ]); + const res = await backendClient.getTreasuryTokenPricesAsync(); + const zrxAmount = Web3Wrapper.toUnitAmount(zrxBalance, 18); + const maticAmount = Web3Wrapper.toUnitAmount(maticBalance, 18); + const zrxUSD = zrxAmount.multipliedBy(res['0x'].usd); + const maticUSD = maticAmount.multipliedBy(res['matic-network'].usd); + + const treasuryTokenTransferData = await backendClient.getTreasuryTokenTransfersAsync(); + const totalDistributed = parseTotalDistributed(treasuryTokenTransferData); + setTotalTreasuryDistributedUSD( + `$${ + formatNumber(totalDistributed, { + decimals: 6, + decimalsRounded: 6, + bigUnitPostfix: true, + }).formatted + }`, + ); + setTotalTreasuryAmountUSD( + `$${ + formatNumber(zrxUSD.plus(maticUSD).toString(), { + decimals: 6, + decimalsRounded: 6, + bigUnitPostfix: true, + }).formatted + }`, + ); + })(); + }, [providerState]); + + const averageVotingPowerFormatted = averageVotingPower + ? formatNumber(averageVotingPower, { + decimals: 6, + decimalsRounded: 6, + bigUnitPostfix: true, + }).formatted + : '-'; + return ( + + + + + {title} + {titleMobile} + {description} + {actions} + + + + {/* Treasury Stats */} + + +
+ + Available Treasury + + {totalTreasuryAmountUSD} +
+
+ + Total Distributed + + {totalTreasuryDistributedUSD} +
+
+ + +
+ + Votes Passed + + {numProposals || 0} +
+
+ + Avg. ZRX Voted Per Proposal + + {averageVotingPowerFormatted} +
+
+ {/* Treasury Details */} +
+
+
+
+
+
+ ); +}; + +GovernanceHero.defaultProps = { + videoChannel: 'youtube', + videoRatio: '21:9', +}; diff --git a/ts/components/governance/voter_breakdown.tsx b/ts/components/governance/voter_breakdown.tsx new file mode 100644 index 000000000..121b5a3fe --- /dev/null +++ b/ts/components/governance/voter_breakdown.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; + +import styled from 'styled-components'; +import { State } from 'ts/redux/reducer'; +import { utils } from 'ts/utils/utils'; + +import { Heading } from 'ts/components/text'; +import { generateUniqueId, Jazzicon } from 'ts/components/ui/jazzicon'; + +import { useAPIClient } from 'ts/hooks/use_api_client'; +import { PoolWithStats } from 'ts/types'; + +interface VoterBreakdownData { + voter: string; + proposalId: string; + support: boolean; + votingPower: string; + voterName?: string; +} + +const VoteRow = styled.tr` + border-bottom: 1px solid #d9d9d9; +`; + +const VoteTable = styled.table` + width: 100%; + margin-bottom: 0.5rem; +`; + +const VoteTableHeaderElement = styled.th` + text-align: left; +`; + +const VoteTableHeaderElementSupport = styled.th` + text-align: center; +`; + +const VoteTableHeaderElementPower = styled.th` + text-align: right; +`; +const VoteRowAddress = styled.td` + padding: 0.5rem 0; + display: flex; +`; + +interface VoterRowSupportProps { + supportColor: string; +} + +const VoteRowSupport = styled.td` + text-align: center; + vertical-align: middle; + font-family: Formular; + font-style: normal; + font-weight: 500; + font-size: 18px; + color: ${(props) => props.supportColor}; +`; + +const VoteRowPower = styled.td` + font-family: Formular; + font-style: normal; + font-weight: 500; + font-size: 18px; + color: #000000; + text-align: right; + vertical-align: middle; +`; + +const VoterAddress = styled.span` + font-family: Formular; + font-style: normal; + font-weight: 500; + font-size: 18px; + color: #000000; + line-height: 30px; + margin-left: 0.5rem; +`; + +const VoterBreakdownWrapper = styled.div` + margin-bottom: 1rem; +`; + +export const VoterBreakdown: React.FC<{ data: VoterBreakdownData[]; shouldShowAll?: boolean }> = ({ + data, + shouldShowAll, +}) => { + const [stakingPools, setStakingPools] = React.useState(undefined); + const networkId = useSelector((state: State) => state.networkId); + const apiClient = useAPIClient(networkId); + + React.useEffect(() => { + const fetchAndSetPoolsAsync = async () => { + const poolsResponse = await apiClient.getStakingPoolsAsync(); + setStakingPools(poolsResponse.stakingPools || []); + }; + // tslint:disable-next-line:no-floating-promises + fetchAndSetPoolsAsync(); + }, [apiClient]); + + let parsedData = null; + + if (data) { + if (!shouldShowAll) { + parsedData = data.slice(0, 5); + } + if (stakingPools) { + parsedData = data.map((vote) => { + const foundPool = stakingPools.find((pool) => { + return vote.voter.toLowerCase() === pool.operatorAddress.toLowerCase(); + }); + if (foundPool) { + vote.voterName = foundPool.metaData.name || `Pool ${foundPool.poolId}`; + } + return vote; + }); + } + } + return ( + + {parsedData && parsedData.length > 0 && ( + <> + Votes: + + + + + Address + voting for + voting power + + + + {parsedData.map((voteData: VoterBreakdownData, index) => { + return ( + + + + + {voteData.voterName || + utils.getAddressBeginAndEnd(voteData.voter, 4, 4)} + + + + {voteData.support ? 'Yes' : 'No'} + + {voteData.votingPower} + + ); + })} + + + {/* + View All + */} + + )} + + ); +}; diff --git a/ts/components/header.tsx b/ts/components/header.tsx index 36aa64e71..742d2e91d 100644 --- a/ts/components/header.tsx +++ b/ts/components/header.tsx @@ -46,7 +46,7 @@ const navItems: NavItemProps[] = [ { id: 'zrx', text: 'ZRX', - url: WebsitePaths.Staking, + url: WebsitePaths.Vote, }, // { // id: 'resources', diff --git a/ts/components/slider/percentage_slider.tsx b/ts/components/slider/percentage_slider.tsx new file mode 100644 index 000000000..4a17e8561 --- /dev/null +++ b/ts/components/slider/percentage_slider.tsx @@ -0,0 +1,244 @@ +/* tslint:disable */ + +import * as React from 'react'; + +const colors = require('nice-color-palettes'); +import { PoolWithStats } from 'ts/types'; + +import styled from 'styled-components'; + +const PercentageSliderWrapper = styled.div` + .tag:last-of-type > .slider-button { + display: none !important; + } +`; + +const randPalette = colors + .sort(function () { + return 0.5 - Math.random(); + }) + .pop(); + +const _tags = [ + { + name: 'Action', + color: 'red', + }, + { + name: 'Romance', + color: 'purple', + }, + { + name: 'Comedy', + color: 'orange', + }, + { + name: 'Horror', + color: 'black', + }, +]; + +const getPercentage = (containerWidth: number, distanceMoved: number) => { + return (distanceMoved / containerWidth) * 100; +}; + +const limitNumberWithinRange = (value: number, min: number, max: number): number => { + return Math.min(Math.max(value, min), max); +}; + +const nearestN = (N: number, number: number) => Math.ceil(number / N) * N; +interface TagSectionProps { + name: string; + color: string; + width: number; + onSliderSelect: (e: React.MouseEvent) => void; +} + +const TagSection = ({ name, color, width, onSliderSelect }: TagSectionProps) => { + return ( +
+ {Math.round(width) + '%'} + +
+
+ ); +}; + +interface PoolDataElement { + pool: PoolWithStats; + zrxStaked: number; +} + +interface PercentageSliderProps { + pools: PoolDataElement[]; + tags: Tag[]; + widths: number[]; + setWidths: (widths: number[]) => void; +} + +interface Tag { + name: string; + color: string; +} + +export const PercentageSlider: React.FC = ({ pools, tags, widths, setWidths }) => { + // const [widths, setWidths] = React.useState(new Array(poolTags.length).fill(100 / poolTags.length)); + // const [tags, setTags] = React.useState(poolTags); + const TagSliderRef = React.useRef(null); + return ( + +
+ {tags.map((tag, index) => ( + { + e.preventDefault(); + document.body.style.cursor = 'ew-resize'; + + const startDragX = e.pageX; + const sliderWidth = TagSliderRef.current.offsetWidth; + + const resize = (ev: PointerEvent) => { + ev.preventDefault(); + const eTouch = (ev as unknown) as TouchEvent; + const endDragX = eTouch.touches ? eTouch.touches[0].pageX : ev.pageX; + const distanceMoved = endDragX - startDragX; + const maxPercent = widths[index] + widths[index + 1]; + + const percentageMoved = nearestN(1, getPercentage(sliderWidth, distanceMoved)); + // const percentageMoved = getPercentage(sliderWidth, distanceMoved); + + const _widths = widths.slice(); + + const prevPercentage = _widths[index]; + + const newPercentage = prevPercentage + percentageMoved; + const currentSectionWidth = limitNumberWithinRange(newPercentage, 0, maxPercent); + _widths[index] = currentSectionWidth; + + const nextSectionIndex = index + 1; + + const nextSectionNewPercentage = _widths[nextSectionIndex] - percentageMoved; + const nextSectionWidth = limitNumberWithinRange( + nextSectionNewPercentage, + 0, + maxPercent, + ); + _widths[nextSectionIndex] = nextSectionWidth; + + setWidths(_widths); + }; + const resizeTouch = (ev: TouchEvent) => { + ev.preventDefault(); + const endDragX = ev.touches[0].pageX; + const distanceMoved = endDragX - startDragX; + const maxPercent = widths[index] + widths[index + 1]; + + const percentageMoved = nearestN(1, getPercentage(sliderWidth, distanceMoved)); + // const percentageMoved = getPercentage(sliderWidth, distanceMoved); + + const _widths = widths.slice(); + + const prevPercentage = _widths[index]; + + const newPercentage = prevPercentage + percentageMoved; + const currentSectionWidth = limitNumberWithinRange(newPercentage, 0, maxPercent); + _widths[index] = currentSectionWidth; + + const nextSectionIndex = index + 1; + + const nextSectionNewPercentage = _widths[nextSectionIndex] - percentageMoved; + const nextSectionWidth = limitNumberWithinRange( + nextSectionNewPercentage, + 0, + maxPercent, + ); + _widths[nextSectionIndex] = nextSectionWidth; + + setWidths(_widths); + }; + // tslint:disable-next-line + window.addEventListener('pointermove', resize); + // tslint:disable-next-line + window.addEventListener('touchmove', resizeTouch); + + const removeEventListener = () => { + // tslint:disable-next-line + window.removeEventListener('pointermove', resize); + // tslint:disable-next-line + window.removeEventListener('touchmove', resizeTouch); + }; + + const handleEventUp = (e: Event) => { + e.preventDefault(); + document.body.style.cursor = 'initial'; + removeEventListener(); + }; + + window.addEventListener('touchend', handleEventUp); + window.addEventListener('pointerup', handleEventUp); + }} + color={tag.color} + /> + ))} +
+
+ ); +}; + +type StylesType = { [key: string]: React.CSSProperties }; + +const styles: StylesType = { + tag: { + padding: 10, + textAlign: 'center', + position: 'relative', + borderRightWidth: '.1em', + borderRightStyle: 'solid', + borderRightColor: 'white', + boxSizing: 'border-box', + borderLeftWidth: '.1em', + borderLeftStyle: 'solid', + borderLeftColor: 'white', + }, + tagText: { + color: 'white', + fontWeight: 700, + userSelect: 'none', + display: 'block', + overflow: 'hidden', + }, + sliderButton: { + width: '1em', + height: '1em', + backgroundColor: 'white', + position: 'absolute', + borderRadius: '2em', + right: 'calc(-.6em)', + top: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + bottom: 0, + margin: 'auto', + zIndex: 10, + cursor: 'ew-resize', + userSelect: 'none', + }, +}; diff --git a/ts/components/staking/add_pool_dialog.tsx b/ts/components/staking/add_pool_dialog.tsx new file mode 100644 index 000000000..4dd2f29a2 --- /dev/null +++ b/ts/components/staking/add_pool_dialog.tsx @@ -0,0 +1,229 @@ +import { DialogContent, DialogOverlay } from '@reach/dialog'; +import '@reach/dialog/styles.css'; +import React, { FC, useMemo, useState } from 'react'; +import styled from 'styled-components'; + +import { Button } from 'ts/components/button'; +import { Icon } from 'ts/components/icon'; +import { Input } from 'ts/components/modals/input'; +import { Thumbnail } from 'ts/components/staking/thumbnail.tsx'; +import { Heading, Paragraph } from 'ts/components/text'; + +import { useSearch } from 'ts/hooks/use_search'; +import { zIndex } from 'ts/style/z_index'; +import { PoolWithStats } from 'ts/types'; +import { colors } from 'ts/utils/colors'; +import { stakingUtils } from 'ts/utils/staking_utils'; + +interface ChangePoolDialogProps { + isOpen: boolean; + onDismiss: () => void; + onAddPool: (poolId: string) => void; + stakingPools: PoolWithStats[]; +} + +interface PoolWithDisplayName extends PoolWithStats { + displayName: string; +} + +const searchOptions = { + keys: ['displayName'], +}; + +export const AddPoolDialog: FC = ({ isOpen, onDismiss, stakingPools, onAddPool }) => { + const stakingPoolsWithName: PoolWithDisplayName[] = useMemo( + () => + stakingPools.sort(stakingUtils.sortByProtocolFeesDesc).map((pool) => ({ + ...pool, + displayName: stakingUtils.getPoolDisplayName(pool), + })), + [stakingPools], + ); + + const { setSearchTerm, searchResults } = useSearch(stakingPoolsWithName, searchOptions); + + const [selectedPoolId, setSelectedPoolId] = useState(undefined); + + const clearAndDismiss = () => { + setSelectedPoolId(undefined); + setSearchTerm(''); + onDismiss(); + }; + + return ( + + + + + + + <> + Select a pool + Choose a liquidity pool from the list to rebalance into. + + + + setSearchTerm(e.target.value)} + /> + + + + {(searchResults.length ? searchResults : stakingPools).map((pool) => ( + setSelectedPoolId(pool.poolId)} + isSelected={pool.poolId === selectedPoolId} + > + + {stakingUtils.getPoolDisplayName(pool)} + + ))} + + + { + onAddPool(selectedPoolId); + clearAndDismiss(); + }} + > + Choose the selected pool + + + + + + ); +}; + +const ButtonWrapper = styled.div` + margin-top: 33px; + display: flex; + justify-content: flex-end; + + @media (max-width: 768px) { + justify-content: center; + } +`; + +const StyledParagraph = styled(Paragraph)` + font-style: normal; + font-weight: 300; + font-size: 18px; + line-height: 26px; +`; + +const StyledHeading = styled(Heading)` + font-size: 34px; + line-height: 42px; + margin-bottom: 20px; + + font-feature-settings: 'tnum' on, 'lnum' on; +`; + +const StyledInput = styled(Input)` + input { + ::placeholder { + color: ${(props) => props.theme.textDarkSecondary}; + } + + background-color: ${(props) => props.theme.lightBgColor}; + border: none; + } +`; + +const InputWrapper = styled.div` + display: flex; + align-items: baseline; + + @media (min-width: 768px) { + box-shadow: 0px 1px 0px #b4bebd; + margin-bottom: 23px; + } +`; + +const PoolsListWrapper = styled.div` + overflow-y: scroll; + @media (min-width: 768px) { + height: 500px; + max-height: 40vh; + } +`; + +const ConfirmButton = styled(Button)` + background-color: ${(props) => props.isDisabled && '#898990'}; + cursor: ${(props) => (props.isDisabled ? 'not-allowed' : 'pointer')}; + color: #fff; + &:hover { + background-color: ${(props) => props.isDisabled && '#898990'}; + } +`; + +const ButtonClose = styled(Button)` + width: 18px; + height: 18px; + border: none; + + align-self: flex-end; + + path { + fill: ${colors.black}; + } +`; + +const Pool = styled.div<{ isSelected?: boolean }>` + height: 87px; + background: #fff; + border: 1px solid ${(props) => (props.isSelected ? '#00AE99' : '#ddd')}; + display: flex; + flex-direction: row; + align-items: center; + cursor: pointer; + + & + & { + margin-top: 14px; + } +`; + +const StyledDialogOverlay = styled(DialogOverlay)` + &[data-reach-dialog-overlay] { + background-color: rgba(255, 255, 255, 0.8); + z-index: ${zIndex.overlay}; + + @media (min-width: 768px) { + overflow: hidden; + } + } +`; + +const StyledDialogContent = styled(DialogContent)` + &[data-reach-dialog-content] { + display: flex; + flex-direction: column; + position: relative; + width: 600px; + background: ${(props) => props.theme.lightBgColor}; + border: 1px solid #e5e5e5; + + @media (max-width: 768px) { + min-height: 100vh; + width: 100vw; + margin: 0; + padding: 30px; + + border: none; + } + } +`; + +const StyledThumbnail = styled(Thumbnail)` + margin: 0 20px; +`; diff --git a/ts/components/staking/gas_ticker.tsx b/ts/components/staking/gas_ticker.tsx index e09f78851..b977bc8c1 100644 --- a/ts/components/staking/gas_ticker.tsx +++ b/ts/components/staking/gas_ticker.tsx @@ -13,6 +13,10 @@ const Container = styled.a` padding: 22px 20px 20px; margin-right: 20px; cursor: pointer; + @media (max-width: 428px) { + width: 40%; + margin-bottom: 20px; + } `; const ExpandedMenu = styled.div` diff --git a/ts/components/staking/header/header.tsx b/ts/components/staking/header/header.tsx index ded10d479..4b51e9fbf 100644 --- a/ts/components/staking/header/header.tsx +++ b/ts/components/staking/header/header.tsx @@ -76,15 +76,20 @@ interface NavItems { } const navItems: NavItems[] = [ + { + id: 'governance', + text: 'Governance', + url: WebsitePaths.Vote, + }, { id: 'staking', text: 'Staking', url: WebsitePaths.Staking, }, { - id: 'governance', - text: 'Governance', - url: WebsitePaths.Vote, + id: 'treasury', + text: 'Treasury', + url: WebsitePaths.Treasury, }, { id: 'your-account', @@ -94,13 +99,14 @@ const navItems: NavItems[] = [ ]; export const Header: React.FC = ({ isNavToggled, toggleMobileNav }) => { - const { connector, deactivate } = useWeb3React(); + const { connector, deactivate, chainId } = useWeb3React(); const providerState = useSelector((state: State) => state.providerState); const { logoutWallet } = useWallet(); const dispatch = useDispatch(); const [dispatcher, setDispatcher] = useState(undefined); const [hasLiveOrUpcomingVotes, setHasLiveOrUpcomingVotes] = useState(false); + const [numberOfLiveVotes, setNumberOfLiveVotes] = useState(undefined); const { data } = useQuery('proposals', async () => { const { proposals: treasuryProposals } = await request(GOVERNANCE_THEGRAPH_ENDPOINT, FETCH_PROPOSALS); @@ -113,13 +119,18 @@ export const Header: React.FC = ({ isNavToggled, toggleMobileNav }) const checkHasLiveOrUpcomingVotes = useCallback((treasuryData) => { const hasZEIPS = ZEIP_PROPOSALS.filter((zeip) => { - return zeip.voteEndDate.isSameOrAfter(moment()); + return zeip.voteEndDate.isSameOrAfter(moment()) && zeip.voteStartDate.isBefore(moment()); }); const hasTreasuryProposals = treasuryData.filter((proposal: OnChainProposal) => { - return moment.unix((proposal.voteEpoch.endTimestamp as unknown) as number).isSameOrAfter(moment()); + return moment + .unix((proposal.voteEpoch.startTimestamp as unknown) as number) + .add(3, 'd') + .isSameOrAfter(moment()); }); + setHasLiveOrUpcomingVotes(hasZEIPS.length || hasTreasuryProposals.length); + setNumberOfLiveVotes(hasZEIPS.length + (hasTreasuryProposals.length as number)); }, []); useEffect(() => { @@ -153,6 +164,8 @@ export const Header: React.FC = ({ isNavToggled, toggleMobileNav }) ); const isWalletConnected = providerState.account.state === AccountState.Ready; + const isUsingCorrectChain = !chainId ? true : chainId === 1; + return ( = ({ isNavToggled, toggleMobileNav }) - + - / ZRX + / ZRX @@ -178,7 +191,9 @@ export const Header: React.FC = ({ isNavToggled, toggleMobileNav })
{link.id === 'governance' && ( - + + {numberOfLiveVotes} + )}
); @@ -198,13 +213,16 @@ export const Header: React.FC = ({ isNavToggled, toggleMobileNav }) toggleMobileNav={toggleMobileNav} hasBackButton={false} hasSearch={false} - navHeight={isWalletConnected ? 426 : 365} + navHeight={isWalletConnected ? 590 : 480} > {subMenu}
+ {!isUsingCorrectChain && !environments.isDevelopment() && ( + Wrong network: Switch to the Ethereum Network + )}
); }; @@ -251,16 +269,20 @@ const WalletConnectedIndicator = styled.div` `; const GovernanceActiveIndicator = styled.div` - width: 10px; - height: 10px; - border-radius: 50%; + width: 15px; + height: 15px; border: 1px solid #ffffff; background-color: #e71d36; transition: opacity 0.25s ease-in; opacity: ${(props) => (props.hasProposals ? 1 : 0)}; position: relative; right: 1.8rem; - top: 0.6rem; + top: 0.2rem; + color: white; + font-size: 8px; + padding-right: 1px; + text-align: center; + padding-top: 3px; z-index: ${zIndex.header + 1}; `; @@ -349,3 +371,10 @@ const LogoWrap = styled.div` display: flex; align-items: center; `; + +const ChainErrorBanner = styled.div` + background-color: #e71d36; + text-align: center; + color: white; + padding: 1rem 0; +`; diff --git a/ts/components/staking/header/sub_menu.tsx b/ts/components/staking/header/sub_menu.tsx index 146b265a1..c539bec2f 100644 --- a/ts/components/staking/header/sub_menu.tsx +++ b/ts/components/staking/header/sub_menu.tsx @@ -121,6 +121,17 @@ const MobileMenuWrapper = styled.div` const GasTickerAndWalletWrapper = styled.div` display: flex; + padding: 15px 0px; + padding-bottom: 30px; + + @media (max-width: 428px) { + display: flex; + flex-direction: column; + } + + @media (max-width: 1199px) { + padding-left: 30px; + } `; const ConnectedWallet = ({ providerState, openConnectWalletDialogCB, logoutWalletCB }: ISubMenuProps) => { @@ -177,9 +188,12 @@ const ConnectButton = styled(Button).attrs({ color: ${colors.black}; @media (max-width: 1199px) { - margin: 30px; width: 315px; } + + @media (max-width: 1400px) { + width: 250px; + } `; export const SubMenu = (props: ISubMenuProps) => { diff --git a/ts/components/staking/stake_rebalance.tsx b/ts/components/staking/stake_rebalance.tsx new file mode 100644 index 000000000..c563174da --- /dev/null +++ b/ts/components/staking/stake_rebalance.tsx @@ -0,0 +1,408 @@ +import * as React from 'react'; + +import * as _ from 'lodash'; + +import styled from 'styled-components'; + +import { MoveStakeData } from 'ts/hooks/use_stake'; +import { stakingUtils } from 'ts/utils/staking_utils'; + +import { Button } from 'ts/components/button'; +import { Icon } from 'ts/components/icon'; +import { PercentageSlider } from 'ts/components/slider/percentage_slider'; +import { AddPoolDialog } from 'ts/components/staking/add_pool_dialog'; +import { ZRXInput } from 'ts/components/staking/staking_calculator'; + +import { Heading } from 'ts/components/text'; + +import { colors } from 'ts/style/colors'; +import { PoolWithStats } from 'ts/types'; + +const randPalette = ['#fe4365', '#fc9d9a', '#f9cdad', '#c8c8a9', '#83af9b']; + +const ButtonClose = styled(Button)` + width: 22px; + height: 22px; + border: none; + align-self: flex-end; + path { + fill: ${colors.textDarkSecondary}; + } +`; + +const AddPoolButton = styled(Button)` + margin-bottom: 20px; +`; +const Container = styled.div` + position: fixed; + right: 0; + top: 0; + background-color: #f6f6f6; + width: 500px; + padding: 2rem; + height: 100%; + z-index: 2; + box-shadow: 10px 0px 20px #000000; + display: flex; + flex-direction: column; +`; + +const StakingPoolsContainer = styled.div` + margin: 1rem 0; +`; + +const StakingPoolWrapper = styled.div` + margin-bottom: 1rem; +`; + +const ButtonsContainer = styled.div` + margin-top: 2rem; + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-end; +`; + +const StakingPoolLabel = styled.div` + margin-bottom: 0.5rem; +`; +const HeadingWrapper = styled.div` + margin: 1rem 0; + font-weight: 400; +`; + +const ColorBox = styled.div` + width: 20px; + height: 20px; + margin-left: 0.5rem; + position: absolute; + margin-top: -0.15rem; + display: inline-block; +`; + +const StakingPoolLabelWrapper = styled.div` + display: flex; + justify-content: space-between; +`; + +const RemovePool = styled.div` + text-decoration: underline; + cursor: pointer; + font-size: 14px; + align-self: flex-end; + margin-bottom: 0.5rem; +`; + +export interface InputProps { + className?: string; + value?: string; + width?: string; + fontSize?: string; + fontColor?: string; + padding?: string; + placeholderColor?: string; + placeholder?: string; + backgroundColor?: string; + onChange?: (event: React.ChangeEvent) => void; +} + +interface PoolDataElement { + pool: PoolWithStats; + zrxStaked: number; +} + +interface PoolDiff { + poolId: string; + diff: number; +} + +interface StakeRebalanceProps { + onClose: () => void; + poolData: PoolDataElement[]; + stakingPools: PoolWithStats[]; + rebalanceStake: (rebalanceStakeData: MoveStakeData[]) => void; +} +export const StakeRebalance: React.FC = ({ onClose, poolData, stakingPools, rebalanceStake }) => { + const originalSumOfZrx = poolData + .map((item) => item.zrxStaked) + .reduce((accumulator, currentValue) => accumulator + currentValue); + + const [sliderPercentages, setSliderPercentages] = React.useState( + new Array(poolData.length).fill(100 / poolData.length), + ); + const [isAddPoolDialogOpen, setIsAddPoolDialogOpen] = React.useState(false); + const [currentPoolData, setCurrentPoolData] = React.useState(poolData); + + const node = React.useRef(); + + // const handleClick = (ev: MouseEvent): any => { + // if (node.current.contains(ev.target as Element)) { + // return; + // } + // onClose(); + // }; + + // React.useEffect(() => { + // document.addEventListener('mousedown', handleClick); + + // return () => { + // document.removeEventListener('mousedown', handleClick); + // }; + // }, []); + + const updateSliderPercentages = (widths: number[]) => { + setSliderPercentages(widths); + const sumOfZrx = currentPoolData + .map((item) => item.zrxStaked) + .reduce((accumulator, currentValue) => accumulator + currentValue); + const updatedCurrentPoolData = currentPoolData.map((item, index) => { + return { + pool: item.pool, + zrxStaked: parseFloat((sumOfZrx * (widths[index] / 100)).toFixed(2)), + }; + }); + setCurrentPoolData(updatedCurrentPoolData); + }; + + const addPool = (poolId: string) => { + const pool = stakingPools.find((stakingPool) => { + return stakingPool.poolId === poolId; + }); + const updatedCurrentPoolData = [ + ...currentPoolData, + { + pool, + zrxStaked: 0, + }, + ]; + const updatedSliderPercentages = new Array(updatedCurrentPoolData.length).fill( + 100 / updatedCurrentPoolData.length, + ); + + const sumOfZrx = updatedCurrentPoolData + .map((item) => item.zrxStaked) + .reduce((accumulator, currentValue) => accumulator + currentValue); + const updatedPoolDataWithStakedAmounts = updatedCurrentPoolData.map((item, index) => { + return { + pool: item.pool, + zrxStaked: parseFloat((sumOfZrx * (updatedSliderPercentages[index] / 100)).toFixed(2)), + }; + }); + setCurrentPoolData(updatedPoolDataWithStakedAmounts); + setSliderPercentages(updatedSliderPercentages); + }; + + const filteredStakingPools = stakingPools.filter((pool) => { + const foundPool = currentPoolData.find((poolDataPool) => { + return poolDataPool.pool.poolId === pool.poolId; + }); + return !foundPool; + }); + + const poolTags = currentPoolData.map((pool, index) => { + return { + name: pool.pool.poolId, + color: randPalette[index], + }; + }); + + const onPoolInputChange = (e: React.ChangeEvent, index: number) => { + const updatedStakedZrx = parseFloat(e.target.value || '0'); + let updatedCurrentPoolData = [...currentPoolData]; + if (updatedStakedZrx <= originalSumOfZrx) { + const remainderZrx = (originalSumOfZrx - updatedStakedZrx) / (updatedCurrentPoolData.length - 1); + updatedCurrentPoolData = updatedCurrentPoolData.map((item, innerIndex) => { + if (index !== innerIndex) { + return { + pool: item.pool, + zrxStaked: remainderZrx, + }; + } + return { + pool: item.pool, + zrxStaked: updatedStakedZrx, + }; + }); + } + + const sumOfZrx = updatedCurrentPoolData + .map((item) => item.zrxStaked) + .reduce((accumulator, currentValue) => accumulator + currentValue); + + const _widths = updatedCurrentPoolData.map((item) => { + return (item.zrxStaked / sumOfZrx) * 100; + }); + + setCurrentPoolData(updatedCurrentPoolData); + setSliderPercentages(_widths); + }; + + const removePool = (poolId: string) => { + const updatedCurrentPoolData = currentPoolData.filter((item) => { + return item.pool.poolId !== poolId; + }); + + const updatedSliderPercentages = new Array(updatedCurrentPoolData.length).fill( + 100 / updatedCurrentPoolData.length, + ); + + const updatedPoolDataWithStakedAmounts = updatedCurrentPoolData.map((item, index) => { + return { + pool: item.pool, + zrxStaked: parseFloat((originalSumOfZrx * (updatedSliderPercentages[index] / 100)).toFixed(2)), + }; + }); + + setCurrentPoolData(updatedPoolDataWithStakedAmounts); + setSliderPercentages(updatedSliderPercentages); + }; + + const generateMoveStakeData = (poolDiff: PoolDiff, reductions: PoolDiff[]): MoveStakeData[] | MoveStakeData => { + const { poolId, diff } = poolDiff; + let accumulatedAmount = 0; + const moveStakeData = []; + for (const element of reductions) { + const availAmt = Math.abs(element.diff); + if (availAmt >= diff - accumulatedAmount) { + moveStakeData.push({ + fromPoolId: element.poolId, + toPoolId: poolId, + zrxAmount: parseFloat((diff - accumulatedAmount).toFixed(2)), + }); + continue; + } + accumulatedAmount += availAmt; + moveStakeData.push({ + fromPoolId: element.poolId, + toPoolId: poolId, + zrxAmount: parseFloat(availAmt.toFixed(2)), + }); + } + return moveStakeData; + }; + + const rebalanceStakeAcrossPools = () => { + const additions: PoolDiff[] = []; + const reductions: PoolDiff[] = []; + + currentPoolData.forEach((item) => { + const foundPool = poolData.find((foundItem) => { + return item.pool.poolId === foundItem.pool.poolId; + }); + const diff = foundPool ? item.zrxStaked - foundPool.zrxStaked : item.zrxStaked; + + if (diff !== 0) { + if (diff > 0) { + additions.push({ + poolId: item.pool.poolId, + diff, + }); + } else { + reductions.push({ + poolId: item.pool.poolId, + diff, + }); + } + } + }); + + if (additions.length > 0) { + const data = _.flatMap(additions, (item) => { + return generateMoveStakeData(item, reductions); + }); + rebalanceStake(data); + } + }; + + return ( + <> + + + + + <> + + + Change your Stake + + + + {currentPoolData.map((data, index) => { + const poolTag = poolTags.find((item) => { + return item.name === data.pool.poolId; + }); + + const isStartingPool = poolData.find((item) => { + return item.pool.poolId === data.pool.poolId; + }); + + return ( +
+ + + + {stakingUtils.getPoolDisplayName(data.pool)} + + + {!isStartingPool && ( + { + removePool(data.pool.poolId); + }} + > + Remove Pool + + )} + + { + onPoolInputChange(e, index); + }} + /> + +
+ ); + })} +
+ {currentPoolData.length > 1 && ( + + )} + + {currentPoolData.length < 4 && ( + { + setIsAddPoolDialogOpen(true); + }} + > + + Add Pool + + )} + + + { + setIsAddPoolDialogOpen(false); + }} + onAddPool={addPool} + /> + +
+ + ); +}; diff --git a/ts/components/staking/staking_calculator.tsx b/ts/components/staking/staking_calculator.tsx index cc1634046..40ff667c2 100644 --- a/ts/components/staking/staking_calculator.tsx +++ b/ts/components/staking/staking_calculator.tsx @@ -135,7 +135,7 @@ const ZRXLabel = styled.label` margin-right: 20px; `; -const ZRXInput: React.FC = ({ value, className, placeholder, onChange }) => ( +export const ZRXInput: React.FC = ({ value, className, placeholder, onChange }) => ( ZRX diff --git a/ts/connectors/index.ts b/ts/connectors/index.ts index 4c983a42e..8c1b52095 100644 --- a/ts/connectors/index.ts +++ b/ts/connectors/index.ts @@ -19,9 +19,7 @@ export const network = new NetworkConnector({ defaultChainId: 1, }); -export const injected = new InjectedConnector({ - supportedChainIds: [1, 3, 4, 5, 42], -}); +export const injected = new InjectedConnector({}); const newWalletConnect = () => new WalletConnectConnector({ diff --git a/ts/hooks/use_allowance.ts b/ts/hooks/use_allowance.ts index e451cd189..ea5e17a02 100644 --- a/ts/hooks/use_allowance.ts +++ b/ts/hooks/use_allowance.ts @@ -40,8 +40,8 @@ export const useAllowance = (): UseAllowanceHookResult => { setIsStarted(true); const ownerAddress = (providerState.account as AccountReady).address; - const localStorageSpeed = localStorage.getItem('gas-speed'); - const gasInfo = await backendClient.getGasInfoAsync(localStorageSpeed || 'standard'); + // const localStorageSpeed = localStorage.getItem('gas-speed'); + const gasInfo = await backendClient.getGasInfoAsync('instant'); const contractAddresses = getContractAddressesForChainOrThrow(networkId); const erc20ProxyAddress = contractAddresses.erc20Proxy; diff --git a/ts/hooks/use_stake.ts b/ts/hooks/use_stake.ts index aa1088cf9..ce61060ce 100644 --- a/ts/hooks/use_stake.ts +++ b/ts/hooks/use_stake.ts @@ -44,6 +44,7 @@ export interface UseStakeHookResult { depositAndStake: (stakingPools: StakePoolData[], callback?: () => void) => void; unstake: (stakePoolData: StakePoolData[], callback?: () => void) => void; moveStake: (fromPoolId: string, toPoolId: string, zrxAmount: number, callback?: () => void) => void; + batchMoveStake: (moveStakeData: MoveStakeData[], callback?: () => void) => void; withdrawStake: (zrxAmountBaseUnits: BigNumber, callback?: () => void) => void; withdrawRewards: (poolIds: string[], callback?: () => void) => void; stakingContract?: StakingContract; @@ -55,6 +56,12 @@ export interface UseStakeHookResult { currentEpochRewards?: BigNumber; } +export interface MoveStakeData { + fromPoolId: string; + toPoolId: string; + zrxAmount: number; +} + export const useStake = (networkId: ChainId, providerState: ProviderState): UseStakeHookResult => { const [loadingState, setLoadingState] = useState(undefined); const [error, setError] = useState(undefined); @@ -111,9 +118,8 @@ export const useStake = (networkId: ChainId, providerState: ProviderState): UseS async (data: string[]) => { setLoadingState(TransactionLoadingState.WaitingForSignature); - const localStorageSpeed = localStorage.getItem('gas-speed'); - const gasInfo = await backendClient.getGasInfoAsync(localStorageSpeed || 'standard'); - + // const localStorageSpeed = localStorage.getItem('gas-speed'); + const gasInfo = await backendClient.getGasInfoAsync('instant'); const txPromise = stakingProxyContract .batchExecute(data) .awaitTransactionSuccessAsync({ from: ownerAddress, gasPrice: gasInfo.gasPriceInWei }); @@ -213,6 +219,28 @@ export const useStake = (networkId: ChainId, providerState: ProviderState): UseS [executeWithData, stakingContract], ); + const batchMoveStakeAsync = useCallback( + async (moveStakeData: MoveStakeData[]) => { + const data = moveStakeData.map((item) => { + const { zrxAmount, fromPoolId, toPoolId } = item; + + const zrxAmountBaseUnits = toZrxBaseUnits(zrxAmount); + const fromPoolIdPadded = utils.toPaddedHex(fromPoolId); + const toPoolIdPadded = utils.toPaddedHex(toPoolId); + + return stakingContract + .moveStake( + { status: StakeStatus.Delegated, poolId: fromPoolIdPadded }, + { status: StakeStatus.Delegated, poolId: toPoolIdPadded }, + zrxAmountBaseUnits, + ) + .getABIEncodedTransactionData(); + }); + await executeWithData(data); + }, + [executeWithData, stakingContract], + ); + const withdrawStakeAsync = useCallback( async (zrxAmountBaseUnits: BigNumber) => { if (zrxAmountBaseUnits.isLessThanOrEqualTo(0) || isTxInProgress(loadingState)) { @@ -221,8 +249,7 @@ export const useStake = (networkId: ChainId, providerState: ProviderState): UseS setLoadingState(TransactionLoadingState.WaitingForSignature); - const localStorageSpeed = localStorage.getItem('gas-speed'); - const gasInfo = await backendClient.getGasInfoAsync(localStorageSpeed || 'standard'); + const gasInfo = await backendClient.getGasInfoAsync('instant'); const txPromise = stakingContract .unstake(zrxAmountBaseUnits) @@ -360,6 +387,17 @@ export const useStake = (networkId: ChainId, providerState: ProviderState): UseS handleError(err); }); }, + batchMoveStake: (moveStakeData: MoveStakeData[], callback?: () => void) => { + batchMoveStakeAsync(moveStakeData) + .then(() => { + trackEvent(TRACKING.MOVE_STAKE, { event_label: 'success' }); + }) + .then(callback) + .catch((err: Error) => { + trackEvent(TRACKING.MOVE_STAKE, { event_label: 'failed' }); + handleError(err); + }); + }, withdrawStake: (zrxAmountBaseUnits: BigNumber, callback?: () => void) => { withdrawStakeAsync(zrxAmountBaseUnits) .then(() => { diff --git a/ts/index.tsx b/ts/index.tsx index 5c5b55160..4f28b8707 100644 --- a/ts/index.tsx +++ b/ts/index.tsx @@ -17,6 +17,7 @@ import { DocsPage } from 'ts/pages/docs/page'; import { DocsTools } from 'ts/pages/docs/tools'; import { Governance } from 'ts/pages/governance/governance'; import { Treasury } from 'ts/pages/governance/treasury'; +import { VoterLeaderboard } from 'ts/pages/governance/voter_leaderboard'; import { store } from 'ts/redux/store'; import { WebsiteLegacyPaths, WebsitePaths } from 'ts/types'; import { muiTheme } from 'ts/utils/mui_theme'; @@ -37,15 +38,15 @@ import { ZeroExApi } from 'ts/pages/api'; // import { CFL } from 'ts/pages/cfl'; // import { NextEcosystem } from 'ts/pages/ecosystem'; import { Extensions } from 'ts/pages/extensions'; +import { TreasuryBreakdown } from 'ts/pages/governance/treasury_breakdown'; import { VoteIndex } from 'ts/pages/governance/vote_index'; // import { Next0xInstant } from 'ts/pages/instant'; import { NextLanding } from 'ts/pages/landing'; -import { NextLaunchKit } from 'ts/pages/launch_kit'; +// import { NextLaunchKit } from 'ts/pages/launch_kit'; // import { NextMarketMaker } from 'ts/pages/market_maker'; import { PrivacyPolicy } from 'ts/pages/privacy'; import { StakingIndex } from 'ts/pages/staking/home'; import { StakingPool } from 'ts/pages/staking/staking_pool'; - import { RemoveStake } from 'ts/pages/staking/wizard/remove'; import { TermsOfService } from 'ts/pages/terms'; @@ -98,7 +99,7 @@ render( {/* */} {/* */} {/* */} - + {/* */} {/* */} @@ -115,7 +116,13 @@ render( component={StakingPoolActivity} /> + + @@ -135,7 +142,7 @@ render( Portal does currently does not support V3 architecture // */} - + = () => { const voteHistory: VoteHistory[] = []; const [stakingError, setStakingError] = React.useState(undefined); + const [stakingErrorMessage, setStakingErrorMessage] = React.useState(undefined); const [isApplyModalOpen, toggleApplyModal] = React.useState(false); const [changePoolDetails, setChangePoolDetails] = React.useState(undefined); const [removeStakePoolDetails, setRemoveStakePoolDetails] = React.useState(undefined); @@ -148,6 +151,8 @@ export const Account: React.FC = () => { const [votingPowerMap, setVotingPowerMap] = React.useState({}); const [hasVotingPower, setHasVotingPower] = React.useState(false); const [shouldOpenStakeDecisionModal, setOpenStakeDecisionModal] = React.useState(false); + + const [isRebalanceOpen, setIsRebalanceOpen] = React.useState(false); // keeping in case we want to make use of by-pool estimated rewards // const [expectedCurrentEpochPoolRewards, setExpectedCurrentEpochPoolRewards] = React.useState(undefined); const [expectedCurrentEpochRewards, setExpectedCurrentEpochRewards] = React.useState(new BigNumber(0)); @@ -162,6 +167,7 @@ export const Account: React.FC = () => { withdrawStake, withdrawRewards, moveStake, + batchMoveStake, currentEpochRewards, error: useStakeError, } = useStake(networkId, providerState); @@ -209,15 +215,15 @@ export const Account: React.FC = () => { }, {}); const _currentEpochStakeMap = delegatorResponse.forCurrentEpoch.poolData.reduce<{ [key: string]: number }>( - (memo, poolData) => { - memo[poolData.poolId] = poolData.zrxStaked || 0; + (memo, pData) => { + memo[pData.poolId] = pData.zrxStaked || 0; return memo; }, {}, ); const _nextEpochStakeMap = delegatorResponse.forNextEpoch.poolData.reduce<{ [key: string]: number }>( - (memo, poolData) => { - memo[poolData.poolId] = poolData.zrxStaked || 0; + (memo, pData) => { + memo[pData.poolId] = pData.zrxStaked || 0; return memo; }, {}, @@ -262,12 +268,12 @@ export const Account: React.FC = () => { .filter((p) => !pendingUnstakePoolSet.has(p.poolId) && p.zrxStaked > 0); const _votingPowerMap = votingPowerPools.reduce<{ [key: string]: number }>( - (memo, poolData) => { - if (poolData.poolId !== DEFAULT_POOL_ID) { - memo[poolData.poolId] = poolData.zrxStaked / 2 || 0; - memo.self += poolData.zrxStaked / 2 || 0; + (memo, pData) => { + if (pData.poolId !== DEFAULT_POOL_ID) { + memo[pData.poolId] = pData.zrxStaked / 2 || 0; + memo.self += pData.zrxStaked / 2 || 0; } else { - memo.selfDelegated += poolData.zrxStaked || 0; + memo.selfDelegated += pData.zrxStaked || 0; } return memo; }, @@ -309,9 +315,7 @@ export const Account: React.FC = () => { React.useEffect(() => { const fetchAvailableRewards = async () => { - const poolsWithAllTimeRewards = delegatorData.allTime.poolData.filter( - (poolData) => poolData.rewardsInEth > 0, - ); + const poolsWithAllTimeRewards = delegatorData.allTime.poolData.filter((pData) => pData.rewardsInEth > 0); const undelegatedBalancesBaseUnits = await stakingContract .getOwnerStakeByStatus(account.address, StakeStatus.Undelegated) @@ -326,8 +330,8 @@ export const Account: React.FC = () => { setUndelegatedBalanceBaseUnits(undelegatedInBothEpochsBaseUnits); const poolRewards: PoolReward[] = await Promise.all( - poolsWithAllTimeRewards.map(async (poolData) => { - const paddedHexPoolId = hexUtils.leftPad(hexUtils.toHex(poolData.poolId)); + poolsWithAllTimeRewards.map(async (pData) => { + const paddedHexPoolId = hexUtils.leftPad(hexUtils.toHex(pData.poolId)); const availableRewardInEth = await stakingContract .computeRewardBalanceOfDelegator(paddedHexPoolId, account.address) @@ -335,7 +339,7 @@ export const Account: React.FC = () => { // TODO(kimpers): There is some typing issue here, circle back later to remove the BigNumber conversion return { - poolId: poolData.poolId, + poolId: pData.poolId, rewardsInEth: Web3Wrapper.toUnitAmount( new BigNumber(availableRewardInEth.toString()), constants.DECIMAL_PLACES_ETH, @@ -407,6 +411,10 @@ export const Account: React.FC = () => { const castedStakeError = (useStakeError as unknown) as RPCError; if (useStakeError && castedStakeError.code === -32000) { setStakingError(useStakeError); + if (castedStakeError.message.includes('max fee per gas less than block base fee')) { + setStakingErrorMessage('Max fee per gas less than block base fee: Please retry shortly'); + } + setStakingErrorMessage(castedStakeError.message); } }, [useStakeError]); @@ -449,6 +457,19 @@ export const Account: React.FC = () => { } const nextEpochStart = nextEpochStats && new Date(nextEpochStats.epochStart.timestamp); + const poolData = delegatorData.forCurrentEpoch.poolData + .map((delegatorPoolStats: PoolEpochDelegatorStats) => { + const poolId = delegatorPoolStats.poolId; + const pool = poolWithStatsMap[poolId]; + return { pool, zrxStaked: delegatorPoolStats.zrxStaked }; + }) + .filter((item) => item.zrxStaked > 0); + + const rebalanceStake = (rebalanceStakeData: MoveStakeData[]) => { + batchMoveStake(rebalanceStakeData, () => { + setIsRebalanceOpen(false); + }); + }; return ( @@ -593,7 +614,7 @@ export const Account: React.FC = () => { {/* TODO add loading animations or display partially loaded data */} {hasDataLoaded() && ( - + Your Staking Pools @@ -686,6 +707,21 @@ export const Account: React.FC = () => { ); }) )} +
+ +
)} {hasDataLoaded() && hasVotingPower && ( @@ -796,14 +832,13 @@ export const Account: React.FC = () => { /> { setStakingError(undefined); }} /> - = () => { )} + + {isRebalanceOpen && ( + { + setIsRebalanceOpen(false); + }} + poolData={poolData} + stakingPools={stakingPools || []} + rebalanceStake={rebalanceStake} + /> + )}
); }; diff --git a/ts/pages/governance/data.ts b/ts/pages/governance/data.ts index e4561e392..43269da5b 100644 --- a/ts/pages/governance/data.ts +++ b/ts/pages/governance/data.ts @@ -598,6 +598,42 @@ export const proposals: Proposals = { ], }, }, + 91: { + zeipId: 91, + title: 'Set the protocol fee multiplier to 0', + summary: [ + `This ZEIP proposes to decrease the protocol fee multiplier from the current value (70,000) to zero (0). The goal is to conduct an experiment that measures the impact on volume in a zero-fee environment to inform decision-making around 0x network economics.`, + `Protocol fees introduced in 0x v3 have been experiencing a sharp decrease in Q3 due to a combination of decreased activity in open orderbook markets, the emergence of Flashbots as an efficient venue for MEV value capture, and increased competition in open orderbook liquidity protocols.`, + `A public dashboard will be produced in time for the change to go into effect to monitor its effects. If this proposal is accepted, the update will become effective after 7 days for 0x v3, and 2 days for 0x v4, due to protocol timelocks.`, + ], + url: 'https://github.com/0xProject/ZEIPs/issues/91', + voteStartDate: moment(1631725200, 'X'), + voteEndDate: moment(1631984400, 'X'), + benefit: { + title: 'Benefit', + summary: [ + `The purpose of the experiment is to measure the effect of the protocol fee on limit orders success rate and 0x open orderbook volume as a whole.`, + `In fact, the presence of the 0x protocol fee requires arbitrageurs to wait for mid-market price to move an extra premium (the cost of the protocol fee) before filling the limit order.`, + ], + rating: 4, + links: [], + }, + risks: { + title: 'Risk', + summary: [ + `The fee multiplier parameter was designed to be updatable to meet evolving environmental conditions. As per the temperature check poll, the community supports this adjustment.`, + `This change will result in no protocol fees collected, hence halting ZRX staking rewards.`, + `Updating the multiplier back to the current value or some new value at the conclusion of the measurement period will require a separate vote as the parameter cannot be set to an arbitrary time period. We propose to run this experiment for a period of at least 6 weeks, adding extra 2 weeks to the initial proposal in the temperature check vote. This additional time will allow mitigating the effects of differences in market conditions in the pre/post periods of the analysis.`, + ], + rating: 1, + links: [ + { + text: 'Temperature Check', + url: 'https://snapshot.org/#/0xgov.eth/proposal/QmUYZwMkDue5RhZGpdGLomsRbKTknmjmjMecz5uz635NFz', + }, + ], + }, + }, }; export const stagingProposals: Proposals = { @@ -628,6 +664,9 @@ export const stagingProposals: Proposals = { 90: { ...proposals[90], }, + 91: { + ...proposals[91], + }, }; export const ZERO_TALLY: TallyInterface = { diff --git a/ts/pages/governance/treasury.tsx b/ts/pages/governance/treasury.tsx index 167c27f67..0164d519e 100644 --- a/ts/pages/governance/treasury.tsx +++ b/ts/pages/governance/treasury.tsx @@ -32,6 +32,11 @@ import { configs, GOVERNANCE_THEGRAPH_ENDPOINT, GOVERNOR_CONTRACT_ADDRESS } from import { documentConstants } from 'ts/utils/document_meta_constants'; import { utils } from 'ts/utils/utils'; +import { VoterBreakdown } from 'ts/components/governance/voter_breakdown'; +import { fetchUtils } from 'ts/utils/fetch_utils'; + +const TREASURY_VOTER_BREAKDOWN_URI = 'https://um5ppgumcc.us-east-1.awsapprunner.com'; + const FETCH_PROPOSAL = gql` query proposal($id: ID!) { proposal(id: $id) { @@ -58,12 +63,20 @@ const FETCH_PROPOSAL = gql` type ProposalState = 'created' | 'active' | 'succeeded' | 'failed' | 'queued' | 'executed'; +interface VoterBreakdownData { + voter: string; + proposalId: string; + support: boolean; + votingPower: string; +} + export const Treasury: React.FC<{}> = () => { const [proposal, setProposal] = React.useState(); const [isProposalsLoaded, setProposalsLoaded] = React.useState(false); const [isVoteReceived, setIsVoteReceived] = React.useState(false); const [isVoteModalOpen, setIsVoteModalOpen] = React.useState(false); const [quorumThreshold, setQuorumThreshold] = React.useState(); + const [voterBreakdownData, setVoterBreakdownData] = React.useState(); const { id: proposalId } = useParams(); const providerState = useSelector((state: State) => state.providerState); @@ -80,6 +93,20 @@ export const Treasury: React.FC<{}> = () => { // tslint:disable-next-line: no-floating-promises (async () => { const qThreshold = await contract.quorumThreshold().callAsync(); + + const result = await fetchUtils.requestAsync(TREASURY_VOTER_BREAKDOWN_URI, `/treasury/${proposalId}`); + const cleanedData = result + .sort((a: VoterBreakdownData, b: VoterBreakdownData) => { + return parseFloat(b.votingPower) - parseFloat(a.votingPower); + }) + .map((vote: VoterBreakdownData) => { + vote.votingPower = utils.getFormattedAmount(new BigNumber(vote.votingPower), 18); + vote.votingPower = vote.votingPower.split('.')[0]; + return vote; + }); + + setVoterBreakdownData(cleanedData); + setQuorumThreshold(qThreshold); })(); }, [providerState]); @@ -229,6 +256,8 @@ export const Treasury: React.FC<{}> = () => { }, }; + let cleanedDescription = description.replace(//g, '\n'); + cleanedDescription = cleanedDescription.replace(heading.raw, ''); return ( @@ -249,8 +278,16 @@ export const Treasury: React.FC<{}> = () => { Submitted by: {utils.getAddressBeginAndEnd(proposal.proposer)} + + + Voter Leaderboard + - + @@ -369,10 +406,12 @@ const StyledMarkdown = styled.div` font-size: 28px; font-weight: 400; margin-bottom: 20px; + color: black; } & p { margin-bottom: 40px; + color: black; } & ul { @@ -483,3 +522,8 @@ const StyledHeading = styled(Heading)` margin-top: 68px; margin-bottom: 24px !important; `; + +const StyledButton = styled(Button)` + margin-bottom: 1rem; +`; +// tslint:disable:max-file-line-count diff --git a/ts/pages/governance/treasury_breakdown.tsx b/ts/pages/governance/treasury_breakdown.tsx new file mode 100644 index 000000000..557b30be6 --- /dev/null +++ b/ts/pages/governance/treasury_breakdown.tsx @@ -0,0 +1,462 @@ +import * as React from 'react'; +import { useSelector } from 'react-redux'; +import styled from 'styled-components'; + +import { Web3Wrapper } from '@0x/web3-wrapper'; +import { Image } from 'ts/components/ui/image'; +import { GOVERNOR_CONTRACT_ADDRESS } from 'ts/utils/configs'; +import { formatNumber } from 'ts/utils/format_number'; + +import { ERC20TokenContract } from '@0x/contract-wrappers'; +import { StakingPageLayout } from 'ts/components/staking/layout/staking_page_layout'; + +import { Paragraph } from 'ts/components/text'; + +import { State } from 'ts/redux/reducer'; +import { backendClient } from 'ts/utils/backend_client'; + +import { PieChart } from 'react-minimal-pie-chart'; + +import { H1, H2, H3, H4 } from 'ts/components/docs/mdx/headings'; + +interface TreasuryBreakdownProps {} + +interface WrapperProps {} + +const Wrapper = styled.div` + width: 100%; + text-align: center; + max-width: 1450px; + margin: 0 auto; + @media (min-width: 768px) { + padding: 30px; + text-align: left; + } +`; + +const Column = styled.div` + width: 50%; + + @media (max-width: 1024px) { + width: 100%; + } +`; + +const AssetsColumn = styled.div` + width: auto; +`; + +const Title = styled.h1` + font-size: 46px; + line-height: 1.2; + font-weight: 300; + margin-bottom: 20px; + display: none; + @media (min-width: 768px) { + font-size: 50px; + display: block; + } +`; + +const ColumnsWrapper = styled.div` + display: flex; + + @media (max-width: 1024px) { + flex-direction: column; + } +`; +const PieChartWrapper = styled.div` + width: 40%; +`; + +const PieAndLegend = styled.div` + display: flex; + justify-content: center; +`; + +const Legend = styled.div` + display: flex-column; + margin-left: 4rem; +`; + +interface ColorBlockProps { + color: string; +} + +const ProposalLink = styled.a` + color: #00ae99; + + &:hover { + text-decoration: underline; + } +`; + +const ColorBlock = styled.div` + background-color: ${(props) => props.color}; + width: 10px; + height: 10px; + + margin-top: 2px; + margin-right: 10px; +`; +const LegendItem = styled.div` + display: flex; + margin-bottom: 5px; +`; + +const BreakdownCopy = styled.div` + margin-top: 4rem; +`; + +const TreasuryAllocations = styled.div` + padding-top: 2rem; +`; + +const AssetsTable = styled.table` + width: 100%; + margin-bottom: 0.5rem; +`; + +const TableHeaderElement = styled.th` + text-align: center; + padding: 15px 80px; + font-size: 15px; + + @media (max-width: 1024px) { + padding: 15px 25px; + } +`; + +const TableHeader = styled.thead` + background-color: #ebefee; + color: #898990; +`; + +const AssetName = styled.td` + text-align: center; + font-size: 20px; + padding-top: 2rem; + + @media (max-width: 600px) { + font-size: 15px; + } +`; + +const AssetBalance = styled.td` + text-align: center; + font-size: 20px; + + @media (max-width: 600px) { + font-size: 15px; + } +`; + +const AssetValue = styled.td` + text-align: center; + font-size: 20px; + + @media (max-width: 600px) { + font-size: 15px; + } +`; + +const AssetRow = styled.tr``; + +const CTADisplay = styled.div` + display: flex; + background-color: #f3f6f4; + width: 100%; + color: #5c5c5c; + justify-content: center; + margin-top: 2rem; + + @media (max-width: 600px) { + padding-left: 2em; + } +`; + +const CTADisplayText = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + padding: 2.5rem 4rem; +`; + +const CTALink = styled.a` + margin-top: 1rem; + color: #00ae99; +`; + +const ArrowCTA = styled.svg` + margin-left: 0.25rem; + margin-top: -4px; +`; + +export const TreasuryBreakdown: React.FC = (props) => { + const providerState = useSelector((state: State) => state.providerState); + + const [totalTreasuryAmountUSD, setTotalTreasuryAmountUSD] = React.useState('-'); + const [zrxUSDValue, setZrxUSDValue] = React.useState(undefined); + const [maticUSDValue, setMaticUSDValue] = React.useState(undefined); + const [assetList, setAssetList] = React.useState([]); + const [allocations, setAllocations] = React.useState([]); + + React.useEffect(() => { + const zrxTokenContract = new ERC20TokenContract( + '0xe41d2489571d322189246dafa5ebde1f4699f498', + providerState.provider, + ); + const maticTokenContract = new ERC20TokenContract( + '0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0', + providerState.provider, + ); + + // tslint:disable-next-line:no-floating-promises + (async () => { + const [zrxBalance, maticBalance] = await Promise.all([ + zrxTokenContract.balanceOf(GOVERNOR_CONTRACT_ADDRESS.ZRX).callAsync(), + maticTokenContract.balanceOf(GOVERNOR_CONTRACT_ADDRESS.ZRX).callAsync(), + ]); + const res = await backendClient.getTreasuryTokenPricesAsync(); + const zrxAmount = Web3Wrapper.toUnitAmount(zrxBalance, 18); + const maticAmount = Web3Wrapper.toUnitAmount(maticBalance, 18); + + const zrxUSD = zrxAmount.multipliedBy(res['0x'].usd); + const maticUSD = maticAmount.multipliedBy(res['matic-network'].usd); + + setZrxUSDValue(zrxUSD); + setMaticUSDValue(maticUSD); + setAssetList([ + { + name: 'ZRX', + balance: zrxAmount.toString(), + usdValue: `$${ + formatNumber(zrxUSD.toString(), { + decimals: 0, + decimalsRounded: 6, + bigUnitPostfix: false, + }).formatted + }`, + }, + { + name: 'MATIC', + balance: maticAmount.toString(), + usdValue: `$${ + formatNumber(maticUSD.toString(), { + decimals: 0, + decimalsRounded: 6, + bigUnitPostfix: false, + }).formatted + }`, + }, + ]); + + const treauryProposalDistributions = await backendClient.getTreasuryProposalDistributionsAsync( + providerState.provider, + ); + + const treasuryAllocations = treauryProposalDistributions.map((distribution: any) => { + const { tokensTransferred } = distribution; + + const updatedTokensTransferred = tokensTransferred.map((token: any) => { + token.usdValue = + token.name === 'ZRX' + ? Math.round(token.amount * res['0x'].usd) + : Math.round(token.amount * res['matic-network'].usd); + + return token; + }); + distribution.tokensTransferred = updatedTokensTransferred; + return distribution; + }); + + setAllocations(treasuryAllocations); + + setTotalTreasuryAmountUSD( + `$${ + formatNumber(zrxUSD.plus(maticUSD).toString(), { + decimals: 6, + decimalsRounded: 6, + bigUnitPostfix: true, + }).formatted + }`, + ); + })(); + }, [providerState]); + + return ( + + +

0x DAO Treasury Breakdown

+ + + {totalTreasuryAmountUSD} + + + {zrxUSDValue && maticUSDValue && ( + `${Math.round(data.dataEntry.percentage)}%`} + labelStyle={{ + fontSize: '8px', + fontFamily: 'sans-serif', + fill: '#f5f5f5', + }} + /> + )} + + +

Legend

+ + ZRX + + + Matic + +
+
+ +

Govern the entire treasury with your ZRX

+ + The intended purpose of the treasury is to fund activities and projects that benefit and + add value to the 0x ecosystem. ZRX holders fully control the treasury. Anyone can submit + a governance proposal to use the funds or apply for funding themselves. + +
+
+ +

Assets

+ + + + Asset + Balance + Value + + + + {assetList && + assetList.map((data, index) => { + return ( + + {data.name} + {data.balance} + {data.usdValue} + + ); + })} + + + + + + Have an idea that requires funding? +
+ + Apply for funds with 0x Eve + + + + +
+
+ + + + Have an idea that requires funding? +
+ + View all active and past proposals + + + + +
+
+
+
+ +

Historical Treasury Allocations

+ + + + Proposal + Transfer Funds + USD Value + + + + {allocations && + allocations.map((data, alloctionsIndex) => { + let summedTokenValue = 0; + let tokenAmountsString = ''; + data.tokensTransferred.forEach((token: any, index: number) => { + summedTokenValue += token.usdValue; + const formattedTokenAmount = formatNumber(token.amount, { + decimals: 0, + decimalsRounded: 6, + bigUnitPostfix: true, + }).formatted; + tokenAmountsString += `${token.name} ${formattedTokenAmount}`; + if (index < data.tokensTransferred.length - 1) { + tokenAmountsString += ', '; + } + }); + const proposalTitle = `Proposal #${data.proposalId}`; + const usd = `$${ + formatNumber(summedTokenValue, { + decimals: 0, + decimalsRounded: 6, + bigUnitPostfix: false, + }).formatted + }`; + return ( + + + + {proposalTitle} + + + {tokenAmountsString} + {usd} + + ); + })} + + +
+
+
+ ); +}; + +TreasuryBreakdown.defaultProps = {}; +// tslint:disable:max-file-line-count diff --git a/ts/pages/governance/vote_form.tsx b/ts/pages/governance/vote_form.tsx index 36dd893cf..d3fec7fb8 100644 --- a/ts/pages/governance/vote_form.tsx +++ b/ts/pages/governance/vote_form.tsx @@ -274,8 +274,8 @@ class VoteFormComponent extends React.Component { const { votePreference } = this.state; - const localStorageSpeed = localStorage.getItem('gas-speed'); - const gasInfo = await backendClient.getGasInfoAsync(localStorageSpeed || 'standard'); + // const localStorageSpeed = localStorage.getItem('gas-speed'); + const gasInfo = await backendClient.getGasInfoAsync('instant'); const txPromise = contract .castVote( diff --git a/ts/pages/governance/vote_index.tsx b/ts/pages/governance/vote_index.tsx index d6b6f362f..753a908ab 100644 --- a/ts/pages/governance/vote_index.tsx +++ b/ts/pages/governance/vote_index.tsx @@ -8,13 +8,17 @@ import * as React from 'react'; import { useQuery } from 'react-query'; import { useSelector } from 'react-redux'; import styled from 'styled-components'; +import { fadeIn } from 'ts/style/keyframes'; + +import { Web3Wrapper } from '@0x/web3-wrapper'; + +import { backendClient } from 'ts/utils/backend_client'; import { Button } from 'ts/components/button'; import { DocumentTitle } from 'ts/components/document_title'; +import { GovernanceHero } from 'ts/components/governance/hero'; import { RegisterBanner } from 'ts/components/governance/register_banner'; -import { Column, Section } from 'ts/components/newLayout'; import { StakingPageLayout } from 'ts/components/staking/layout/staking_page_layout'; -import { Heading, Paragraph } from 'ts/components/text'; import { Text } from 'ts/components/ui/text'; import { Proposal, proposals as prodProposals, stagingProposals, TreasuryProposal } from 'ts/pages/governance/data'; import { VoteIndexCard } from 'ts/pages/governance/vote_index_card'; @@ -22,7 +26,6 @@ import { State } from 'ts/redux/reducer'; import { colors } from 'ts/style/colors'; import { OnChainProposal, TallyInterface, VotingCardType } from 'ts/types'; import { configs, GOVERNANCE_THEGRAPH_ENDPOINT, GOVERNOR_CONTRACT_ADDRESS } from 'ts/utils/configs'; -import { constants } from 'ts/utils/constants'; import { documentConstants } from 'ts/utils/document_meta_constants'; import { environments } from 'ts/utils/environments'; @@ -57,6 +60,33 @@ type TreasuryProposalWithOrder = TreasuryProposal & { order?: number; }; +interface IInputProps { + isSubmitted: boolean; + name: string; + type: string; + label: string; + color?: string; + required?: boolean; + onChange: (event: React.ChangeEvent) => void; +} + +interface IArrowProps { + isSubmitted: boolean; +} + +interface SnapshotProposals { + id: string; + votes?: any[]; + title: string; + choices: string[]; + body: string; + start: number; + end: number; + author: string; + state: string; + order?: number; +} + const PROPOSALS = environments.isProduction() ? prodProposals : stagingProposals; const ZEIP_IDS = Object.keys(PROPOSALS).map((idString) => parseInt(idString, 10)); const ZEIP_PROPOSALS: ProposalWithOrder[] = ZEIP_IDS.map((id) => PROPOSALS[id]).sort( @@ -107,33 +137,50 @@ const fetchVoteStatusAsync: (zeipId: number) => Promise = async } }; -const sortProposals = (onChainProposals: Proposals[], zeipProposals: Proposal[]) => { - let i = 0; - let j = 0; - let order = 0; - while (i < onChainProposals.length && j < zeipProposals.length) { - const treasury = onChainProposals[i]; - const zeip = zeipProposals[j]; - const treasuryStartDate = moment(treasury.startDate); - const zeipStartDate = moment(zeip.voteStartDate); - if (treasuryStartDate.isAfter(zeipStartDate)) { - onChainProposals[i].order = order++; - i++; +const sortProposals = ( + onChainProposals: Proposals[], + zeipProposals: Proposal[], + snapshotProposals: SnapshotProposals[], +) => { + // aggregate all proposals + const allData = [...onChainProposals, ...zeipProposals, ...snapshotProposals]; + + // sort aggregated proposals + const allDataSorted = allData.sort((propA: Proposals, propB: Proposals) => { + const aStart = propA.startDate + ? propA.startDate + : propA.voteStartDate + ? propA.voteStartDate + : moment.unix(propA.start); + const bStart = propB.startDate + ? propB.startDate + : propB.voteStartDate + ? propB.voteStartDate + : moment.unix(propB.start); + return bStart.diff(aStart); + }); + + // set overall order based on aggregated sorting + allDataSorted.forEach((proposal: Proposals, index) => { + if (proposal.zeipId) { + const foundIndex = ZEIP_PROPOSALS.findIndex((p) => { + return p.zeipId === proposal.zeipId; + }); + ZEIP_PROPOSALS[foundIndex].order = index; } else { - ZEIP_PROPOSALS[j].order = order++; - j++; + if (proposal.startDate) { + const foundIndex = onChainProposals.findIndex((p) => { + return p.id === proposal.id; + }); + onChainProposals[foundIndex].order = index; + } else { + const foundIndex = snapshotProposals.findIndex((p) => { + return p.id === proposal.id; + }); + snapshotProposals[foundIndex].order = index; + } } - } - - while (i < onChainProposals.length) { - onChainProposals[i].order = order++; - i++; - } - - while (j < zeipProposals.length) { - ZEIP_PROPOSALS[j].order = order++; - j++; - } + }); }; const fetchTallysAsync: () => Promise = async () => { @@ -147,14 +194,40 @@ interface Proposals { [index: string]: any; } +const Input = React.forwardRef((props: IInputProps, ref: React.Ref) => { + const { name, label, type, onChange } = props; + const id = `input-${name}`; + + return ( + <> + + + + ); +}); + export const VoteIndex: React.FC = () => { const [filter, setFilter] = React.useState('all'); const [tallys, setTallys] = React.useState(undefined); const [proposals, setProposals] = React.useState([]); + const [snapshotProposals, setSnapshotProposals] = React.useState(undefined); const [quorumThreshold, setQuorumThreshold] = React.useState(); const [isExpanded, setIsExpanded] = React.useState(false); + const [isSubmitted, setIsSubmitted] = React.useState(false); const providerState = useSelector((state: State) => state.providerState); + const [email, setEmail] = React.useState(''); + + React.useEffect(() => { + // tslint:disable-next-line: no-floating-promises + (async () => { + const proposalsAndVotes = await backendClient.getSnapshotProposalsAndVotesAsync(); + setSnapshotProposals(proposalsAndVotes); + })(); + }, []); + const { data, isLoading } = useQuery('proposals', async () => { const { proposals: treasuryProposals } = await request(GOVERNANCE_THEGRAPH_ENDPOINT, FETCH_PROPOSALS); return treasuryProposals; @@ -178,7 +251,7 @@ export const VoteIndex: React.FC = () => { }, [providerState]); React.useEffect(() => { - if (data && quorumThreshold) { + if (data && quorumThreshold && snapshotProposals) { const onChainProposals = data.map((proposal: OnChainProposal) => { const { id, votesAgainst, votesFor, description, executionTimestamp, voteEpoch } = proposal; const againstVotes = new BigNumber(votesAgainst); @@ -212,10 +285,10 @@ export const VoteIndex: React.FC = () => { endDate, }; }); - sortProposals(onChainProposals.reverse(), ZEIP_PROPOSALS); + sortProposals(onChainProposals.reverse(), ZEIP_PROPOSALS, snapshotProposals); setProposals(onChainProposals); } - }, [data, quorumThreshold]); + }, [data, quorumThreshold, snapshotProposals]); const applyFilter = (value: string) => { setFilter(value); @@ -234,15 +307,153 @@ export const VoteIndex: React.FC = () => { } }; + const onSubmit = React.useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + if (!email) { + return; + } + try { + await backendClient.subscribeToNewsletterAsync({ + email, + list: configs.STAKING_UPDATES_NEWSLETTER_ID, + }); + } catch (err) { + // console.error(err); + } + }, + [email], + ); const showZEIP = ['all', 'zeip']; const showTreasury = ['all', 'treasury']; + const numProposals = + proposals.filter((proposal) => { + return !proposal.happening && !proposal.upcoming; + }).length + + ZEIP_PROPOSALS.filter((zeip) => { + return zeip.voteEndDate.isBefore(moment.now()); + }).length + + snapshotProposals?.length || 0; + + let sumOfTotalVotingPowerAverage; + if (proposals.length && ZEIP_PROPOSALS.length) { + let sumOfZEIPVotingPower; + let sumOfTreasuryVotingPower; + proposals.forEach((proposal) => { + const tally = { + no: new BigNumber(proposal.againstVotes.toString()), + yes: new BigNumber(proposal.forVotes.toString()), + zeip: proposal.id, + }; + sumOfTreasuryVotingPower = tally.no.plus(tally.yes); + }); + ZEIP_PROPOSALS.forEach((zeip) => { + const tally = tallys && tallys[zeip.zeipId]; + + sumOfZEIPVotingPower = tally.no.plus(tally.yes); + }); + sumOfTotalVotingPowerAverage = + (Web3Wrapper.toUnitAmount(sumOfTreasuryVotingPower, 18).toNumber() / proposals.length + + Web3Wrapper.toUnitAmount(sumOfZEIPVotingPower, 18).toNumber() / ZEIP_PROPOSALS.length) / + 2; + } return ( -
- + {/*
*/} + + Make an impact +
+ with your ZRX + + } + numProposals={numProposals} + averageVotingPower={sumOfTotalVotingPowerAverage} + titleMobile="Make an impact with your ZRX" + description={
Govern the exchange infrastructure of the Internet
} + figure={<>} + actions={ + <> + + + + // tslint:disable-next-line: jsx-curly-spacing + } + /> + + + +
Get notified whenever there's a new vote. (We won't spam)
+
+ { + setIsSubmitted(true); + // tslint:disable-next-line: no-floating-promises + onSubmit(event); + }} + > + {isSubmitted ? ( + 🎉 Thank you for signing up! + ) : ( + + + setEmail(e.target.value)} + required={true} + /> + + + )} + + + + + + + +
+ {/* Govern 0x Protocol @@ -262,14 +473,14 @@ export const VoteIndex: React.FC = () => { - -
+
*/} + {/*
*/} {isLoading ? ( ) : ( - + setIsExpanded((_isExpanded) => !_isExpanded)}> {getFilterName(filter)} @@ -319,6 +530,18 @@ export const VoteIndex: React.FC = () => { /> ); })} + {snapshotProposals && + snapshotProposals.length > 0 && + snapshotProposals.map((proposal: any, index: number) => { + return ( + + ); + })} )}
@@ -331,18 +554,6 @@ const VoteIndexCardWrapper = styled.div` flex-direction: column; `; -const SubtitleContentWrap = styled.div` - max-width: 450px; - margin: auto; - & > * { - display: inline; - } -`; - -const ButtonWrapper = styled.div` - margin-left: 0.5rem; -`; - const LoaderWrapper = styled.div` display: flex; align-items: center; @@ -418,3 +629,95 @@ const StyledText = styled(Text)` font-size: 18px; } `; + +const INPUT_HEIGHT = '60px'; + +const StyledForm = styled.form` + display: flex; + margin-right: 45px; +`; + +const InputsWrapper = styled.div` + display: flex; +`; + +const EmailWrapper = styled.div` + width: 400px; + margin-right: 0.75rem; + @media (max-width: 768px) { + width: 250px; + margin-bottom: 1rem; + } +`; +const StyledInput = styled.input` + appearance: none; + background-color: transparent; + border: 0; + border-bottom: 1px solid ${({ color }) => color || '#393939'}; + color: ${({ theme }) => theme.textColor}; + height: ${INPUT_HEIGHT}; + font-size: 1.3rem; + outline: none; + width: 100%; + + &::placeholder { + color: #b1b1b1; + } +`; + +const SubmitButton = styled.button` + height: ${INPUT_HEIGHT}; + background-color: transparent; + border: 0; + outline: 0; + cursor: pointer; + padding: 0; + margin-left: -40px; +`; + +const SuccessText = styled.p` + margin-right: 30px; + font-size: 1rem; + font-weight: 300; + line-height: ${INPUT_HEIGHT}; + animation: ${fadeIn} 0.5s ease-in-out; +`; + +const SignupArrow = styled.svg` + fill: ${({ color }) => color}; + transform: ${({ isSubmitted }) => isSubmitted && `translateX(44px)`}; + transition: transform 0.25s ease-in-out; +`; + +const NewVoteNotificationSignup = styled.div` + display: flex; + text-align: center; + + margin: 0 auto; + margin-bottom: 30px; + max-width: 1390px; + justify-content: space-between; + background-color: #f2f4f3; + padding: 40px 80px; + text-align: left; + + @media (max-width: 900px) { + flex-direction: column; + } + + @media (max-width: 600px) { + flex-direction: column; + padding: 20px 40px; + } +`; + +const SignupCTA = styled.div` + display: flex; + align-items: center; + max-width: 40%; + @media (max-width: 900px) { + max-width: 100%; + margin-bottom: 1rem; + } +`; +// tslint:disable:max-file-line-count diff --git a/ts/pages/governance/vote_index_card.tsx b/ts/pages/governance/vote_index_card.tsx index 96422db74..5b1021e9e 100644 --- a/ts/pages/governance/vote_index_card.tsx +++ b/ts/pages/governance/vote_index_card.tsx @@ -44,7 +44,22 @@ interface TreasuryCardProps { againstVotes: BigNumber; } -type VoteIndexCardProps = ZEIPCardProps | TreasuryCardProps; +interface SnapshotCardProps { + id: string; + type: VotingCardType.Snapshot; + votes?: any[]; + title: string; + choices: string[]; + body: string; + start: number; + end: number; + author: string; + state: string; + order?: number; + tally?: TallyInterface; +} + +type VoteIndexCardProps = ZEIPCardProps | TreasuryCardProps | SnapshotCardProps; const getVoteTime = (voteStartDate: moment.Moment, voteEndDate: moment.Moment): VoteTime | undefined => { const now = moment(); @@ -71,11 +86,11 @@ export const getVoteOutcome = (tally?: TallyInterface): VoteOutcome | undefined export const getDateString = (voteStartDate: moment.Moment, voteEndDate: moment.Moment): string => { const voteTime = getVoteTime(voteStartDate, voteEndDate); - const pstOffset = '-0800'; + const pstOffset = '-0700'; const now = moment(); const endDate = voteEndDate.utcOffset(pstOffset); const startDate = voteStartDate.utcOffset(pstOffset); - const timeToEndInDays = endDate.diff(now, 'days'); + const timeToEndInDays = (endDate.diff(now, 'days') as number) + 1; const timeToEndInHours = endDate.diff(now, 'hours'); if (voteTime === 'happening') { return `Voting ends in ${timeToEndInDays > 1 ? timeToEndInDays : timeToEndInHours} ${ @@ -140,7 +155,10 @@ export const VoteIndexCard: React.StatelessComponent = (prop > - Treasury + + Treasury + Gas Required + {description ? ( <> @@ -195,7 +213,10 @@ export const VoteIndexCard: React.StatelessComponent = (prop > - ZEIP + + ZEIP + Free Vote + {`${title} `} {`(ZEIP-${zeipId})`} @@ -222,6 +243,62 @@ export const VoteIndexCard: React.StatelessComponent = (prop ); + case VotingCardType.Snapshot: + const snapshotText = props.body.length > 500 ? `${props.body.substring(0, 500)}...` : props.body; + const status = props.state; + + const proposalState = status === 'active' ? 'happening' : 'accepted'; + return ( + +
+ + + + Snapshot + Free Vote + + {`${props.title} `} + {props.body ? ( + <> + {snapshotText} + + ) : ( + +
+
+
+
+
+
+ + )} + + +
+ + {/* {isHappening ? ( + + ) : ( + + {`${totalBalances} ZRX Total Vote`} + + )} */} + + {getDateString(moment.unix(props.start), moment.unix(props.end))} + +
+
+ +
+
+ ); default: return null; } @@ -246,6 +323,33 @@ const Tag = styled.div` width: 50px; background-color: ${() => colors.brandLight}; } + &.freevote { + background-color: transparent; + color: ${() => colors.brandLight}; + border: 1px solid ${() => colors.brandLight}; + width: 87px; + margin-left: 6px; + } + + &.gas-required { + background-color: transparent; + color: ${() => colors.yellow500}; + border: 1px solid ${() => colors.yellow500}; + width: 120px; + margin-left: 6px; + } + + &.freevote-snapshot { + background-color: transparent; + color: #0500fa; + border: 1px solid #0500fa; + width: 87px; + margin-left: 6px; + } + &.snapshot { + width: 90px; + background-color: ${() => colors.blue700}; + } `; const shimmer = keyframes` @@ -255,6 +359,10 @@ const shimmer = keyframes` } `; +const TagsWrapper = styled.div` + display: flex; +`; + const VoteCardShimmer = styled.div` padding-top: 16px; height: 100%; diff --git a/ts/pages/governance/vote_status_text.tsx b/ts/pages/governance/vote_status_text.tsx index 9c19b7b75..2b7f7c2b9 100644 --- a/ts/pages/governance/vote_status_text.tsx +++ b/ts/pages/governance/vote_status_text.tsx @@ -34,9 +34,10 @@ const renderCross = (width: number = 14) => ( export interface VoteStatusTextProps { status: VoteStatus; + isSnapshot?: boolean; } -export const VoteStatusText: React.StatelessComponent = ({ status }) => { +export const VoteStatusText: React.StatelessComponent = ({ status, isSnapshot }) => { switch (status) { case 'upcoming': return ( @@ -63,7 +64,7 @@ export const VoteStatusText: React.StatelessComponent = ({ return ( - Vote Now + Vote Now {isSnapshot ? 'on Snapshot' : ''} ); diff --git a/ts/pages/governance/voter_leaderboard.tsx b/ts/pages/governance/voter_leaderboard.tsx new file mode 100644 index 000000000..d6597f148 --- /dev/null +++ b/ts/pages/governance/voter_leaderboard.tsx @@ -0,0 +1,132 @@ +import { BigNumber } from '@0x/utils'; +import { gql, request } from 'graphql-request'; +import * as _ from 'lodash'; +import CircularProgress from 'material-ui/CircularProgress'; +import * as React from 'react'; +import { useQuery } from 'react-query'; +import { useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; + +import { StakingPageLayout } from 'ts/components/staking/layout/staking_page_layout'; +import { State } from 'ts/redux/reducer'; +import { colors } from 'ts/style/colors'; +import { GOVERNANCE_THEGRAPH_ENDPOINT } from 'ts/utils/configs'; +import { utils } from 'ts/utils/utils'; + +import { VoterBreakdown } from 'ts/components/governance/voter_breakdown'; +import { fetchUtils } from 'ts/utils/fetch_utils'; + +const TREASURY_VOTER_BREAKDOWN_URI = 'https://um5ppgumcc.us-east-1.awsapprunner.com'; + +const FETCH_PROPOSAL = gql` + query proposal($id: ID!) { + proposal(id: $id) { + id + proposer + description + votesFor + votesAgainst + createdTimestamp + voteEpoch { + id + startTimestamp + endTimestamp + } + executionEpoch { + id + startTimestamp + endTimestamp + } + executionTimestamp + } + } +`; + +interface VoterBreakdownData { + voter: string; + proposalId: string; + support: boolean; + votingPower: string; +} + +export const VoterLeaderboard: React.FC<{}> = () => { + const [voterBreakdownData, setVoterBreakdownData] = React.useState(); + const { id: proposalId } = useParams(); + const providerState = useSelector((state: State) => state.providerState); + + useQuery('proposal', async () => { + const { proposal: proposalFromGraph } = await request(GOVERNANCE_THEGRAPH_ENDPOINT, FETCH_PROPOSAL, { + id: proposalId, + }); + + return proposalFromGraph; + }); + + React.useEffect(() => { + // tslint:disable-next-line: no-floating-promises + (async () => { + const result = await fetchUtils.requestAsync(TREASURY_VOTER_BREAKDOWN_URI, `/treasury/${proposalId}`); + const cleanedData = result + .sort((a: VoterBreakdownData, b: VoterBreakdownData) => { + return parseFloat(b.votingPower) - parseFloat(a.votingPower); + }) + .map((vote: VoterBreakdownData) => { + vote.votingPower = utils.getFormattedAmount(new BigNumber(vote.votingPower), 18); + vote.votingPower = vote.votingPower.split('.')[0]; + return vote; + }); + + setVoterBreakdownData(cleanedData); + })(); + }, [providerState]); + return ( + + + + Treasury Proposal {proposalId}/ + Voter Leaderboard + + + {voterBreakdownData && } + {!voterBreakdownData && ( + + + + )} + + + ); +}; + +const ArrowLink = styled.a` + margin-right: 1rem; + color: #5c5c5c; +`; + +const ProposalLink = styled.a` + color: #5c5c5c; + margin-right: 0.25rem; +`; + +const VoterLeaderboardContainer = styled.div` + padding: 0px 2rem; + margin: 0 auto; + max-width: 1500px; +`; + +const VoterLeaderboardHeader = styled.div` + background-color: #f6f6f6; + margin: 0 auto; + max-width: 1500px; + padding: 2rem; + margin-bottom: 1.5rem; +`; + +const LoaderWrapper = styled.div` + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +`; diff --git a/ts/pages/staking/home.tsx b/ts/pages/staking/home.tsx index 132ad17a2..af03f3c67 100644 --- a/ts/pages/staking/home.tsx +++ b/ts/pages/staking/home.tsx @@ -44,6 +44,22 @@ const HeadingRow = styled.div` } `; +const StakingBanner = styled.div` + background-color: #003831; + text-align: center; + color: white; + padding: 1rem; +`; + +const StakingBannerLink = styled.a` + text-decoration: underline; + color: white; + &:hover { + text-decoration: underline; + color: white; + cursor: pointer; + } +`; export interface StakingIndexProps {} export const StakingIndex: React.FC = () => { const [stakingPools, setStakingPools] = React.useState(undefined); @@ -154,6 +170,10 @@ export const StakingIndex: React.FC = () => { return ( + + Protocol fees are paused for 6 weeks as a result of a ZRX vote.{' '} + Learn more + diff --git a/ts/types.ts b/ts/types.ts index 122541f84..4aded65dc 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -582,6 +582,7 @@ export enum WebsitePaths { Careers = '/careers', Credits = '/credits', Vote = '/zrx/vote', + Treasury = '/zrx/treasury', Register = '/zrx/register-to-vote', Extensions = '/extensions', // Explore = '/explore', @@ -1326,6 +1327,7 @@ export interface AllTimeDelegatorStats { export enum VotingCardType { Treasury = 'TREASURY', Zeip = 'ZEIP', + Snapshot = 'SNAPSHOT', } export interface OnChainProposal { diff --git a/ts/utils/backend_client.ts b/ts/utils/backend_client.ts index 3e0fe5ef2..64ffd80c8 100644 --- a/ts/utils/backend_client.ts +++ b/ts/utils/backend_client.ts @@ -1,6 +1,9 @@ import { BigNumber } from '@0x/utils'; import * as _ from 'lodash'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import { ZeroExProvider } from 'ethereum-types'; + import { GasInfo, MailchimpSubscriberInfo, @@ -18,6 +21,8 @@ import { constants } from 'ts/utils/constants'; import { fetchUtils } from 'ts/utils/fetch_utils'; import { utils } from 'ts/utils/utils'; +import { gql, GraphQLClient } from 'graphql-request'; + const ZEROEX_GAS_API = 'https://gas.api.0x.org/'; const ETH_GAS_STATION_ENDPOINT = '/eth_gas_station'; @@ -48,11 +53,156 @@ const speedToWaitTimeMap: { [key: string]: string } = { instant: 'fastestWait', }; +const graphqlClient = new GraphQLClient('https://hub.snapshot.org/graphql'); + export const backendClient = { + async getSnapshotSpaceAsync(): Promise { + const spaceQuery = gql` + query GetSpace($id: String!) { + space(id: $id) { + id + name + about + network + symbol + members + } + } + `; + + const res = await graphqlClient.request(spaceQuery, { id: '0xgov.eth' }); + return res; + }, + async getSnapshotProposalsAsync(): Promise { + const proposalsQuery = gql` + query { + proposals(first: 1000, where: { space_in: ["0xgov.eth"] }) { + id + title + body + choices + start + end + snapshot + state + author + space { + id + name + } + } + } + `; + + const res = await graphqlClient.request(proposalsQuery, {}); + return res; + }, + + async getSnapshotVotesAsync(proposalId: string): Promise { + const votesQuery = gql` + query GetVotes($proposal: String!) { + votes(first: 1000, skip: 0, where: { proposal: $proposal }) { + id + voter + created + choice + space { + id + } + } + } + `; + + const res = await graphqlClient.request(votesQuery, { proposal: proposalId }); + return res; + }, + + async getSnapshotProposalsAndVotesAsync(): Promise { + // tslint:disable-next-line: no-invalid-this + const res = await this.getSnapshotProposalsAsync(); + const { proposals } = res; + + const getVotesPromises = []; + + for (const element of proposals) { + // tslint:disable-next-line: no-invalid-this + getVotesPromises.push(this.getSnapshotVotesAsync(element.id)); + } + + const votesResult = await Promise.all(getVotesPromises); + + return proposals.map((proposal: any, index: number) => { + const votes = votesResult[index]; + proposal.votes = votes; + return proposal; + }); + }, + + async getTreasuryTokenPricesAsync(): Promise { + const treasuryTokenCGIds = ['0x', 'matic-network']; + const cgSimplePriceBaseUri = 'https://api.coingecko.com/api/v3/simple/price'; + const res = fetchUtils.requestAsync( + cgSimplePriceBaseUri, + `?ids=${treasuryTokenCGIds.join(',')}&vs_currencies=usd`, + ); + return res; + }, + + async getTreasuryTokenTransfersAsync(): Promise { + const ZRX_TOKEN = '0xe41d2489571d322189246dafa5ebde1f4699f498'; + const MATIC_TOKEN = '0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0'; + const reqBaseUri = + 'https://api.covalenthq.com/v1/1/address/0x0bB1810061C2f5b2088054eE184E6C79e1591101/transfers_v2/'; + const zrxTransfers = fetchUtils.requestAsync( + reqBaseUri, + `?contract-address=${ZRX_TOKEN}&key=ckey_6a1cbb454aa243b1bc66da64530`, + ); + const maticTransfers = fetchUtils.requestAsync( + reqBaseUri, + `?contract-address=${MATIC_TOKEN}&key=ckey_6a1cbb454aa243b1bc66da64530`, + ); + + return Promise.all([zrxTransfers, maticTransfers]); + }, + + async getTreasuryProposalDistributionsAsync(provider: ZeroExProvider): Promise { + const web3 = new Web3Wrapper(provider); + const reqBaseUri = `https://api.covalenthq.com/v1/1/transaction_v2/`; + const proposalExecutionLogs = await web3.getLogsAsync({ + address: '0x0bb1810061c2f5b2088054ee184e6c79e1591101', + topics: ['0x712ae1383f79ac853f8d882153778e0260ef8f03b504e2866e0593e04d2b291f'], + fromBlock: 0, + toBlock: 'latest', + }); + + const txnHashesPromises = proposalExecutionLogs.map(async (log) => { + return fetchUtils.requestAsync(reqBaseUri, `${log.transactionHash}/?key=ckey_6a1cbb454aa243b1bc66da64530`); + }); + + const transactions = await Promise.all(txnHashesPromises); + const data = transactions.map((txn) => { + let proposalId; + const tokensTransferred: any[] = []; + txn.data.items[0].log_events.forEach((log: any) => { + if (log.decoded.name === 'ProposalExecuted') { + proposalId = log.decoded.params[0].value; + } else if (log.decoded.name === 'Transfer') { + tokensTransferred.push({ + name: log.sender_contract_ticker_symbol, + amount: parseInt(log.decoded.params[2].value, 10) / 10 ** (log.sender_contract_decimals || 18), + }); + } + }); + + return { proposalId, tokensTransferred, hash: txn.data.items[0].tx_hash }; + }); + return data; + }, + async getGasInfoAsync(speed?: string): Promise { // Median gas prices across 0x api gas oracles // Defaulting to average/standard gas. Using eth gas station for time estimates - const gasApiPath = 'source/median?output=eth_gas_station'; + const gasApiPath = 'source/gas_now?output=eth_gas_station'; const gasInfoReq = fetchUtils.requestAsync(ZEROEX_GAS_API, gasApiPath); const speedInput = speed || 'standard'; @@ -75,7 +225,7 @@ export const backendClient = { }, async getGasInfoSelectionAsync(): Promise { - const gasApiPath = 'source/median?output=eth_gas_station'; + const gasApiPath = 'source/gas_now?output=eth_gas_station'; const gasInfoReq = fetchUtils.requestAsync(ZEROEX_GAS_API, gasApiPath); const gasInfo: WebsiteBackendGasInfo = await gasInfoReq; @@ -90,6 +240,7 @@ export const backendClient = { const result = await fetchUtils.requestAsync(utils.getBackendBaseUrl(), JOBS_ENDPOINT); return result; }, + async getPriceInfoAsync(tokenSymbols: string[]): Promise { if (_.isEmpty(tokenSymbols)) { return {}; @@ -101,14 +252,17 @@ export const backendClient = { const result = await fetchUtils.requestAsync(utils.getBackendBaseUrl(), PRICES_ENDPOINT, queryParams); return result; }, + async getRelayerInfosAsync(): Promise { const result = await fetchUtils.requestAsync(utils.getBackendBaseUrl(), RELAYERS_ENDPOINT); return result; }, + async getTokenInfosAsync(): Promise { const result = await fetchUtils.requestAsync(utils.getBackendBaseUrl(), TOKENS_ENDPOINT); return result; }, + async subscribeToNewsletterAsync({ email, subscriberInfo, @@ -131,15 +285,19 @@ export const backendClient = { }); return result; }, + async getCFLMetricsAsync(): Promise { return fetchUtils.requestAsync(utils.getBackendBaseUrl(), CFL_METRICS_ENDPOINT); }, + async getTradingPairsAsync(): Promise { return fetchUtils.requestAsync(utils.getBackendBaseUrl(), TRADING_PAIRS_ENDPOINT); }, + async getStakingPoolsAsync(): Promise { return fetchUtils.requestAsync(utils.getBackendBaseUrl(), STAKING_POOLS_ENDPOINT); }, + async getStakingPoolAsync(id: string): Promise { return fetchUtils.requestAsync(utils.getBackendBaseUrl(), `${STAKING_POOLS_ENDPOINT}/${id}`); }, diff --git a/yarn.lock b/yarn.lock index 395e44da0..1484f7982 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2015,6 +2015,11 @@ "@ethersproject/properties" "^5.0.3" "@ethersproject/strings" "^5.0.4" +"@graphql-typed-document-node/core@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.1.0.tgz#0eee6373e11418bfe0b5638f654df7a4ca6a3950" + integrity sha512-wYn6r8zVZyQJ6rQaALBEln5B1pzxb9shV5Ef97kTvn6yVGrqyXVnDqnU24MXnFubR+rZjBY9NWuxX3FB2sTsjg== + "@jest/console@^24.7.1", "@jest/console@^24.9.0": version "24.9.0" resolved "https://registry.yarnpkg.com/@jest/console/-/console-24.9.0.tgz#79b1bc06fb74a8cfb01cbdedf945584b1b9707f0" @@ -3086,6 +3091,14 @@ semver "^6.3.0" tsutils "^3.17.1" +"@urql/core@^2.1.4": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@urql/core/-/core-2.3.0.tgz#3bc34e46e0ac9646a5bfcfad2c93872a1d20106b" + integrity sha512-pBw3LxqoYroXlLb+jvWrLyNpGl2WRvga9ZSco99aG/o/pMKb5ugfDVPPGPE7UCh7IGI4rb/2xPEmkiWdrtYbkg== + dependencies: + "@graphql-typed-document-node/core" "^3.1.0" + wonka "^4.0.14" + "@walletconnect/client@^1.3.4": version "1.3.4" resolved "https://registry.yarnpkg.com/@walletconnect/client/-/client-1.3.4.tgz#6468f562a5df59dd33498da2649508e6b8261e63" @@ -9302,7 +9315,7 @@ got@7.1.0, got@^7.1.0: url-parse-lax "^1.0.0" url-to-options "^1.0.1" -got@9.6.0: +got@9.6.0, got@^9.2.2: version "9.6.0" resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q== @@ -9328,19 +9341,19 @@ graceful-fs@^4.1.10, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1. version "1.0.1" resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" -graphql-request@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/graphql-request/-/graphql-request-3.4.0.tgz#3a400cd5511eb3c064b1873afb059196bbea9c2b" - integrity sha512-acrTzidSlwAj8wBNO7Q/UQHS8T+z5qRGquCQRv9J1InwR01BBWV9ObnoE+JS5nCCEj8wSGS0yrDXVDoRiKZuOg== +graphql-request@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/graphql-request/-/graphql-request-3.5.0.tgz#7e69574e15875fb3f660a4b4be3996ecd0bbc8b7" + integrity sha512-Io89QpfU4rqiMbqM/KwMBzKaDLOppi8FU8sEccCE4JqCgz95W9Q8bvxQ4NfPALLSMvg9nafgg8AkYRmgKSlukA== dependencies: cross-fetch "^3.0.6" extract-files "^9.0.0" form-data "^3.0.0" -graphql@^15.5.0: - version "15.5.0" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5" - integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA== +graphql@^15.5.1: + version "15.5.1" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.1.tgz#f2f84415d8985e7b84731e7f3536f8bb9d383aad" + integrity sha512-FeTRX67T3LoE3LWAxxOlW2K3Bz+rMYAC18rRguK4wgXaTZMiJwSUwDmPFo3UadAKbzirKIg5Qy+sNJXbpPRnQw== growly@^1.3.0: version "1.3.0" @@ -11898,6 +11911,13 @@ map-cache@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" +map-limit@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/map-limit/-/map-limit-0.0.1.tgz#eb7961031c0f0e8d001bf2d56fab685d58822f38" + integrity sha1-63lhAxwPDo0AG/LVb6toXViCLzg= + dependencies: + once "~1.3.0" + map-obj@^1.0.0, map-obj@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" @@ -12586,10 +12606,25 @@ neo-async@^2.6.0, neo-async@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" +new-array@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/new-array/-/new-array-1.0.0.tgz#5dbc639d961eac7f1a9fbc1a7146ec12f2924fbf" + integrity sha1-XbxjnZYerH8an7wacUbsEvKST78= + next-tick@1, next-tick@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" +nice-color-palettes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/nice-color-palettes/-/nice-color-palettes-3.0.0.tgz#1ec31927cfc4ce8a51822b6bab2c64845d181abb" + integrity sha512-lL4AjabAAFi313tjrtmgm/bxCRzp4l3vCshojfV/ij3IPdtnRqv6Chcw+SqJUhbe7g3o3BecaqCJYUNLswGBhQ== + dependencies: + got "^9.2.2" + map-limit "0.0.1" + minimist "^1.2.0" + new-array "^1.0.0" + nice-try@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.4.tgz#d93962f6c52f2c1558c0fbda6d512819f1efe1c4" @@ -13019,6 +13054,13 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" +once@~1.3.0: + version "1.3.3" + resolved "https://registry.yarnpkg.com/once/-/once-1.3.3.tgz#b2e261557ce4c314ec8304f3fa82663e4297ca20" + integrity sha1-suJhVXzkwxTsgwTz+oJmPkKXyiA= + dependencies: + wrappy "1" + onetime@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.0.tgz#fff0f3c91617fe62bb50189636e99ac8a6df7be5" @@ -14495,6 +14537,11 @@ react-markdown@^4.0.6: unist-util-visit "^1.3.0" xtend "^4.0.1" +react-minimal-pie-chart@^8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/react-minimal-pie-chart/-/react-minimal-pie-chart-8.2.0.tgz#22b53af2363f040d818331721658dfa7a1ea847a" + integrity sha512-RhrHzprJt3KfBe4L3sE0Ha6fj4kYcwQtesQgscnld9Umf64+nZnxxInycnbimKsbIjxJONv77JIZp+qRbJD+bA== + react-popper@^1.0.0-beta.6: version "1.0.0-beta.6" resolved "https://registry.npmjs.org/react-popper/-/react-popper-1.0.0-beta.6.tgz#cb27a2ac56adccbaf5f9c4132387289069240834" @@ -17790,6 +17837,14 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +urql@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/urql/-/urql-2.0.4.tgz#8d6d9f98521c45afc56ca1373141a5cc767af49f" + integrity sha512-ARITb+l+DsGbK/y3mxQjbC4lw8Z4IqFfk4Va8Eg1rDpN1HhOkMTxFA/YvGZoQIU1re/SsARLe//TEBtsgH0/CQ== + dependencies: + "@urql/core" "^2.1.4" + wonka "^4.0.14" + use-callback-ref@^1.2.1, use-callback-ref@^1.2.3: version "1.2.4" resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.2.4.tgz#d86d1577bfd0b955b6e04aaf5971025f406bea3c" @@ -18900,6 +18955,11 @@ window-size@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075" +wonka@^4.0.14: + version "4.0.15" + resolved "https://registry.yarnpkg.com/wonka/-/wonka-4.0.15.tgz#9aa42046efa424565ab8f8f451fcca955bf80b89" + integrity sha512-U0IUQHKXXn6PFo9nqsHphVCE5m3IntqZNB9Jjn7EB1lrR7YTDY3YWgFvEvwniTzXSvOH/XMzAZaIfJF/LvHYXg== + word-wrap@~1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"