diff --git a/package.json b/package.json index 3fd6fde7..2191972c 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,7 @@ "@ethersproject/address": "^5.0.10", "@ethersproject/experimental": "5.4.0", "@ethersproject/providers": "5.4.5", - "@fuul/sdk": "^4.7.1", - "@opendollar/sdk": "1.7.3-rc.1", + "@opendollar/sdk": "1.7.4-rc.1", "@opendollar/svg-generator": "1.0.5", "@react-spring/web": "^9.7.3", "@sentry/cli": "^2.31.0", diff --git a/public/index.html b/public/index.html index 6940ab24..bd43f768 100644 --- a/public/index.html +++ b/public/index.html @@ -8,7 +8,7 @@ script-src 'self' blob: https://kb.wowto.ai https://app.wowto.ai http://cdn.matomo.cloud/usekeyp.matomo.cloud/matomo.js https://cdn.matomo.cloud/usekeyp.matomo.cloud/matomo.js https://cdn.matomo.cloud/matomo.js https://usekeyp.matomo.cloud/matomo.js; media-src 'self'; img-src 'self' data: blob: https://explorer-api.walletconnect.com https://usekeyp.matomo.cloud https://app.opendollar.com; - connect-src 'self' blob: https://*.quiknode.pro https://api.opensea.io https://api.fuul.xyz https://api.camelot.exchange https://opt-mainnet.g.alchemy.com https://arb-mainnet.g.alchemy.com https://mainnet.optimism.io/ https://eth.llamarpc.com https://base.llamarpc.com https://polygon-bor-rpc.publicnode.com https://eth-pokt.nodies.app https://polygon-pokt.nodies.app https://op-pokt.nodies.app https://arb-pokt.nodies.app https://holy-damp-firefly.arbitrum-mainnet.quiknode.pro https://api.studio.thegraph.com https://od-subgraph-node-image.onrender.com https://usekeyp.matomo.cloud https://o1016103.ingest.us.sentry.io/api/4507153379295232/envelope/ https://o1016103.ingest.us.sentry.io/api/4507153379295232/security/ https://arbitrum-sepolia.infura.io https://arbitrum-sepolia.blockpi.network/v1/rpc/public https://arbitrum.blockpi.network/v1/rpc/public https://optimism.blockpi.network wss://relay.walletconnect.com/ https://verify.walletconnect.org wss://www.walletlink.org/rpc https://explorer-api.walletconnect.com https://chain-proxy.wallet.coinbase.com https://rpc.walletconnect.com https://bot.opendollar.com https://bot.dev.opendollar.com https://subgraph.reflexer.finance/subgraphs/name/reflexer-labs/rai https://api.country.is/ ; + connect-src 'self' blob: https://eth-pokt.nodies.app http://localhost:3000 https://*.quiknode.pro https://api.opensea.io https://api.fuul.xyz https://api.camelot.exchange https://opt-mainnet.g.alchemy.com https://arb-mainnet.g.alchemy.com https://mainnet.optimism.io/ https://eth.llamarpc.com https://base.llamarpc.com https://polygon-bor-rpc.publicnode.com https://eth-pokt.nodies.app https://polygon-pokt.nodies.app https://op-pokt.nodies.app https://arb-pokt.nodies.app https://holy-damp-firefly.arbitrum-mainnet.quiknode.pro https://api.studio.thegraph.com https://od-subgraph-node-image.onrender.com https://usekeyp.matomo.cloud https://o1016103.ingest.us.sentry.io/api/4507153379295232/envelope/ https://o1016103.ingest.us.sentry.io/api/4507153379295232/security/ https://arbitrum-sepolia.infura.io https://arbitrum-sepolia.blockpi.network/v1/rpc/public https://arbitrum.blockpi.network/v1/rpc/public https://optimism.blockpi.network wss://relay.walletconnect.com/ https://verify.walletconnect.org wss://www.walletlink.org/rpc https://explorer-api.walletconnect.com https://chain-proxy.wallet.coinbase.com https://rpc.walletconnect.com https://bot.opendollar.com https://bot.dev.opendollar.com https://subgraph.reflexer.finance/subgraphs/name/reflexer-labs/rai https://api.country.is/ ; object-src 'self' blob:; form-action 'self'; font-src 'self' data: https://fonts.gstatic.com; @@ -58,8 +58,8 @@ OD | App - - + + diff --git a/src/App.tsx b/src/App.tsx index b79584cc..03a634a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,10 +26,11 @@ import GeoBlockContainer from './containers/GeoBlockContainer' import * as Sentry from '@sentry/react' import Earn from './containers/Earn' import Bolts from './containers/Bolts' -import { Fuul } from '@fuul/sdk' import EarnDetails from './containers/Earn/EarnDetails' import Marketplace from './containers/Marketplace' import ScreenLoader from '~/components/Modals/ScreenLoader' +import Explore from '~/containers/Explore' + import 'react-loading-skeleton/dist/skeleton.css' const ToastContainer = lazy(() => import('react-toastify').then((module) => ({ default: module.ToastContainer }))) @@ -47,20 +48,6 @@ Sentry.init({ environment: process.env.NODE_ENV, }) -const network = process.env.REACT_APP_NETWORK_ID -const fuulApiKey = process.env.REACT_APP_FUUL_API_KEY -// Only initialize Fuul on Arbitrum One -if (network === '42161' && fuulApiKey) { - try { - Fuul.init({ - apiKey: fuulApiKey, - }) - Fuul.sendPageview() - } catch (e) { - console.log(e) - } -} - const App = () => { const location = useLocation() @@ -101,6 +88,7 @@ const App = () => { + = ({ address, userFuulDataAddress, data }) => { +const AddressCell: React.FC = ({ address, userBoltsDataAddress, data }) => { const userInTop10 = useMemo(() => data.find((user) => user.rank <= 10 && user.address === address), [data, address]) // Skip ENS check for users not in the top 10 const resolvedAddress = useAddress(address, 0, !userInTop10) return (
- {userFuulDataAddress === address && ( + {userBoltsDataAddress === address && ( = ({ address, userFuulDataAddress, export default React.memo(AddressCell, (prevProps, nextProps) => { return ( prevProps.address === nextProps.address && - prevProps.userFuulDataAddress === nextProps.userFuulDataAddress && + prevProps.userBoltsDataAddress === nextProps.userBoltsDataAddress && prevProps.data === nextProps.data ) }) diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 578316a9..830ccf69 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -21,9 +21,6 @@ const Footer: React.FC = () => {
PROJECT
- - Join Testnet - Docs diff --git a/src/components/Steps.tsx b/src/components/Steps.tsx index 446424bc..0a9d32f6 100644 --- a/src/components/Steps.tsx +++ b/src/components/Steps.tsx @@ -14,7 +14,6 @@ import { COIN_TICKER } from '~/utils' import useGeb from '~/hooks/useGeb' import { Tooltip as ReactTooltip } from 'react-tooltip' import { checkUserGasBalance } from '~/utils' -import useFuulSDK from '~/hooks/useFuulSDK' import LowGasModal from './Modals/LowGasModal' const Steps = () => { @@ -28,7 +27,6 @@ const Steps = () => { const { popupsModel: popupsActions, connectWalletModel: connectWalletActions } = useStoreActions((state) => state) const addTransaction = useTransactionAdder() - const { sendConnectWalletEvent } = useFuulSDK() const { step, isWrongNetwork, isStepLoading, blockNumber, ctHash } = connectWalletState @@ -42,14 +40,6 @@ const Steps = () => { return } try { - const referralProgram = localStorage.getItem('referralProgram') === 'true' - if (referralProgram) { - try { - await sendConnectWalletEvent(account) - } catch (e) { - console.debug('User declined Fuul program ', e) - } - } const txData = await geb.contracts.proxyRegistry.populateTransaction['build()']() const signer = provider.getSigner(account) connectWalletActions.setIsStepLoading(true) diff --git a/src/containers/Analytics/index.tsx b/src/containers/Analytics/index.tsx index 9298d20a..1cec3dbb 100644 --- a/src/containers/Analytics/index.tsx +++ b/src/containers/Analytics/index.tsx @@ -68,6 +68,7 @@ const Analytics = () => { globalDebt, globalDebtCeiling, globalDebtUtilization, + surplusInTreasury, marketPrice, redemptionPrice, totalLiquidity, @@ -162,6 +163,13 @@ const Analytics = () => { const circulation = { title: 'circulation', value: erc20Supply, description: 'Circulating supply of OD stablecoin' } + const surplusInTreasuryData: DataCardProps = { + title: 'Surplus in Treasury', + value: surplusInTreasury, + description: + "Total OD accrued by the system's stability fees. It's stored in the Stability Fee Treasury accountance", + } + const liquidityUniswap = { title: 'OD/ETH Liquidity in Camelot', value: totalLiquidity, @@ -244,6 +252,7 @@ const Analytics = () => { // totalFeesEarned, globalDebtUtilizationData, globalDebtData, + surplusInTreasuryData, ] const pricesData: DataCardProps[] = [ @@ -267,7 +276,6 @@ const Analytics = () => { 0, true ).toString() - const colRows = Object.fromEntries( Object.entries(analyticsData?.tokenAnalyticsData).map(([key, value]) => { const lockedAmountInUsd = multiplyWad( @@ -324,7 +332,7 @@ const Analytics = () => { analyticsData.globalDebt, analyticsData.globalDebtCeiling ), - surplusInTreasury: formatDataNumber(analyticsData.surplusInTreasury, 18, 0, true), + surplusInTreasury: `${formatDataNumber(analyticsData.surplusInTreasury, 18, 0)} OD`, marketPrice: formatDataNumber(analyticsData.marketPrice, 18, 3, true, undefined, 4), redemptionPrice: formatDataNumber(analyticsData.redemptionPrice, 18, 3, true, undefined, 4), totalLiquidity: formattedLiquidity, diff --git a/src/containers/Bolts/Affiliate.tsx b/src/containers/Bolts/Affiliate.tsx deleted file mode 100644 index e3cda72e..00000000 --- a/src/containers/Bolts/Affiliate.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { useState, useEffect } from 'react' -import styled from 'styled-components' -import { useActiveWeb3React } from '~/hooks/useActiveWeb3React' -import { useStoreActions } from '~/store' - -import useFuulSDK from '~/hooks/useFuulSDK' -import Button from '~/components/Button' -import CopyToClipboard from 'react-copy-to-clipboard' -import CopyIcon from '~/components/Icons/CopyIcon' - -const Affiliate = () => { - const { account } = useActiveWeb3React() - const { createAffiliateCode, getAffiliateCode } = useFuulSDK() - const [affiliateCode, setAffiliateCode] = useState('') - const [newAffiliateCode, setNewAffiliateCode] = useState('') - const [error, setError] = useState(null) - const [copied, setCopied] = useState(false) - const [hasFetched, setHasFetched] = useState(false) - - const { popupsModel: popupsActions } = useStoreActions((state) => state) - const handleConnectWallet = () => popupsActions.setIsConnectorsWalletOpen(true) - - useEffect(() => { - if (account && !hasFetched) { - setHasFetched(true) - ;(async () => { - try { - const code = await getAffiliateCode(account) - if (code) { - setAffiliateCode(code) - } - } catch (err) { - console.error('Error fetching referral code:', err) - } - })() - } - }, [account, getAffiliateCode, hasFetched]) - - const handleCreateAffiliateCode = async () => { - if (account && newAffiliateCode) { - try { - await createAffiliateCode(account, newAffiliateCode) - setAffiliateCode(newAffiliateCode) - setNewAffiliateCode('') - setError(null) - } catch (err) { - console.error('Error creating referral code:', err) - setError((err as Error)?.message || 'Failed to create referral code. Please try again.') - } - } - } - - return ( - - - {affiliateCode ? ( - <> - - - Your Referral Link:{' '} - {`https://app.opendollar.com?af=${affiliateCode}`} - - - { - setCopied(true) - setTimeout(() => setCopied(false), 1500) - }} - > - - - - - - ) : ( - <> - - https://app.opendollar.com?af= - setNewAffiliateCode(e.target.value)} - /> - - - {account ? ( - - ) : ( - - )} - - - {error && {error}} - - )} - - - ) -} - -export default Affiliate - -const CopyIconContainer = styled.div` - margin-left: 4px; -` - -const AffiliateText = styled.div` - display: flex; - justify-content: space-between; - font-style: italic; - font-size: ${(props) => props.theme.font.small}; -` - -const Container = styled.div` - background-color: rgba(0, 0, 0, 0.02); - backdrop-filter: blur(10px); - - border: 1px solid rgba(255, 255, 255, 0); - border-radius: 3px; - box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); - - display: flex; - justify-content: flex-start; - margin-top: 20px; - padding: 20px; - border-radius: 5px; - width: 100%; - - @media (max-width: 767px) { - justify-content: center; - } -` - -const Content = styled.div` - display: flex; - align-items: center; - column-gap: 10px; - - input { - padding: 10px; - border: 1px solid #ccc; - border-radius: 4px; - width: 100%; - } - @media (max-width: 767px) { - flex-direction: column; - row-gap: 20px; - } -` - -const FlexContainer = styled.div` - display: flex; - align-items: center; - justify-content: center; - min-height: 59px; - - input { - flex-grow: 1; - } - - @media (max-width: 767px) { - flex-direction: column; /* Stack elements vertically */ - align-items: flex-start; - } -` - -const UrlText = styled.div` - text-wrap: nowrap; - font-style: italic; - font-size: ${(props) => props.theme.font.small}; -` -const BoldText = styled.span` - margin-left: 10px; - margin-right: 10px; - font-weight: bold; - font-size: ${(props) => props.theme.font.small}; -` - -const ErrorMessage = styled.div` - color: red; - margin-top: 10px; -` - -const BtnWrapper = styled.div` - width: max-content; - margin-right: auto; - button { - height: 49px; - text-transform: uppercase; - font-weight: 700; - font-size: 18px; - display: flex; - width: 280px; - } -` diff --git a/src/containers/Bolts/Leaderboard.tsx b/src/containers/Bolts/Leaderboard.tsx index 7a64955d..cbf759b9 100644 --- a/src/containers/Bolts/Leaderboard.tsx +++ b/src/containers/Bolts/Leaderboard.tsx @@ -21,7 +21,7 @@ import { LeaderboardUser } from '~/model/boltsModel' const columnHelper = createColumnHelper() // @ts-ignore -const Table = ({ data, userFuulData }) => { +const Table = ({ data, userBoltsData }) => { const [sorting, setSorting] = useState([]) const [globalFilter, setGlobalFilter] = useState('') const [isTableReady, setIsTableReady] = useState(false) @@ -39,16 +39,16 @@ const Table = ({ data, userFuulData }) => { const displayData = useMemo(() => { let dataToDisplay = [...data.slice(0, 10)] - if (userFuulData.points) { + if (userBoltsData.bolts) { const userInTop10 = data.find( - (user: LeaderboardUser) => user.address === userFuulData.address && user.rank <= 10 + (user: LeaderboardUser) => user.address === userBoltsData.address && user.rank <= 10 ) if (!userInTop10) { - dataToDisplay.push(userFuulData) + dataToDisplay.push(userBoltsData) } } return dataToDisplay - }, [data, userFuulData]) + }, [data, userBoltsData]) const columns = [ columnHelper.accessor('rank', { @@ -60,7 +60,7 @@ const Table = ({ data, userFuulData }) => { if (rank <= 3) { color = '#FFFFFF' badge = - } else if (rank === userFuulData.rank) { + } else if (rank === userBoltsData.rank) { color = '#1A74EC' } return ( @@ -75,11 +75,11 @@ const Table = ({ data, userFuulData }) => { header: 'Address', cell: (info) => { const address = info.getValue() - return + return }, }), - columnHelper.accessor('points', { - header: 'Points', + columnHelper.accessor('bolts', { + header: 'Bolts', // @ts-ignore cell: (info) => {info.getValue().toLocaleString()}, }), @@ -144,7 +144,7 @@ const Table = ({ data, userFuulData }) => { key={row.id} style={ // @ts-ignore - row.original.address === userFuulData.address + row.original.address === userBoltsData.address ? { backgroundColor: '#8DB2FF99' } : { backgroundColor: '#1A74EC' } } @@ -156,7 +156,7 @@ const Table = ({ data, userFuulData }) => { else tdStyle = { paddingTop: '10px', color: '#eeeeee' } if (index === 0) tdStyle.paddingBottom = '10px' // @ts-ignore - if (row?.original.address === userFuulData.address) tdStyle.color = '#1A74EC' + if (row?.original.address === userBoltsData.address) tdStyle.color = '#1A74EC' return ( diff --git a/src/containers/Bolts/index.tsx b/src/containers/Bolts/index.tsx index a845ccd0..8528ec84 100644 --- a/src/containers/Bolts/index.tsx +++ b/src/containers/Bolts/index.tsx @@ -4,47 +4,48 @@ import { useActiveWeb3React } from '~/hooks' import Button from '~/components/Button' import { MULTIPLIERS, QUESTS } from './quests' import QuestBlock from './QuestBlock' -import Image from '~/assets/quests-img.png' import styled from 'styled-components' import Leaderboard from '~/containers/Bolts/Leaderboard' import { useStoreState, useStoreActions } from '~/store' +import DataCard from '~/containers/Analytics/DataCard' const Bolts = () => { const { account } = useActiveWeb3React() - const userFuulData = useStoreState((state) => state.boltsModel.userFuulData) + const userBoltsData = useStoreState((state) => state.boltsModel.userBoltsData) const leaderboardData = useStoreState((state) => state.boltsModel.leaderboardData) const boltsEarnedData = useStoreState((state) => state.boltsModel.boltsEarnedData) + const multipliersData = useStoreState((state) => state.boltsModel.multipliersData) + const fetchData = useStoreActions((actions) => actions.boltsModel.fetchData) useEffect(() => { fetchData({ account } as { account: string | null }) }, [account, fetchData]) - return (
Bolts Welcome Vault Keepers!
+
- Leaderboard - + + + +
+
- - - -

Complete the quests below to earn Bolts.

-

- Deposits, borrows, and LPs are awarded Bolts based on their equivalent value in ETH. For - program details, see our{' '} - - blog - - . -

-
-
+ Leaderboard +
@@ -56,7 +57,7 @@ const Bolts = () => {
Multipliers - {MULTIPLIERS(boltsEarnedData).map((quest, index) => ( + {MULTIPLIERS(multipliersData).map((quest, index) => ( ))}
@@ -79,56 +80,71 @@ const Bolts = () => { ) } +const FlexMultipleRow = styled.div` + display: flex; + gap: 24px; + margin-bottom: 24px; + + ${({ theme }) => theme.mediaWidth.upToSmall` + display: block; + + & div { + margin-bottom: 24px; + } + `} +` + const Container = styled.div` margin: 80px auto; max-width: 1362px; @media (max-width: 767px) { margin: 50px auto; + padding: 0 10px; } color: ${(props) => props.theme.colors.accent}; ` -const MessageBox = styled.div` - max-width: 800px; - margin-left: auto; - margin-right: auto; - border-radius: 4px; - background: ${(props) => props.theme.colors.gradientBg}; - color: white; - padding-left: 28px; - display: flex; - align-items: center; - - & h3 { - font-size: 32px; - font-weight: 700; - font-family: ${(props) => props.theme.family.headers}; - margin-bottom: 10px; - line-height: 36px; - } - - a { - text-decoration: underline; - color: white; - } - - @media (max-width: 767px) { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding-left: 0; - padding-bottom: 36px; - padding-left: 25px; - padding-right: 25px; - border-radius: 0; - } -` - -const Text = styled.div` - max-width: 400px; -` +// const MessageBox = styled.div` +// max-width: 800px; +// margin-left: auto; +// margin-right: auto; +// border-radius: 4px; +// background: ${(props) => props.theme.colors.gradientBg}; +// color: white; +// padding-left: 28px; +// display: flex; +// align-items: center; + +// & h3 { +// font-size: 32px; +// font-weight: 700; +// font-family: ${(props) => props.theme.family.headers}; +// margin-bottom: 10px; +// line-height: 36px; +// } + +// a { +// text-decoration: underline; +// color: white; +// } + +// @media (max-width: 767px) { +// display: flex; +// flex-direction: column; +// align-items: center; +// text-align: center; +// padding-left: 0; +// padding-bottom: 36px; +// padding-left: 25px; +// padding-right: 25px; +// border-radius: 0; +// } +// ` + +// const Text = styled.div` +// max-width: 400px; +// ` const Title = styled.h2` font-size: 34px; @@ -184,6 +200,6 @@ const BtnWrapper = styled.div` } ` -const Link = styled.a`` +// const Link = styled.a`` export default Bolts diff --git a/src/containers/Bolts/quests.tsx b/src/containers/Bolts/quests.tsx index 830df806..3c8cd215 100644 --- a/src/containers/Bolts/quests.tsx +++ b/src/containers/Bolts/quests.tsx @@ -1,11 +1,11 @@ import Button from '~/components/Button' import { ExternalLink } from 'react-feather' import styled from 'styled-components' -import Affiliate from './Affiliate' import zealyLogo from '~/assets/zealy.svg' import galxeLogo from '~/assets/galxe.svg' import camelotLogo from '~/assets/camelot.svg' import odLogo from '~/assets/od-full-logo-light.svg' +import turtleClubLogo from '~/assets/turtle-club.png' import { useHistory } from 'react-router-dom' import TokenIcon from '~/components/TokenIcon' @@ -20,6 +20,12 @@ const LogoText = styled.p` align-items: center; ` +const TurtleClubLogo = () => ( + + Turtle Club Turtle Club + +) + const CamelotLogo = () => ( Camelot @@ -108,29 +114,29 @@ export type BoltsEarnedData = { [key: string]: string } -export const MULTIPLIERS = (boltsEarnedData: BoltsEarnedData) => [ +export type MultipliersData = { + [key: string]: string +} + +export const MULTIPLIERS = (multipliersData: MultipliersData) => [ { - title: 'Invite a Friend', - text: ( - <> - Create a referral link by signing a message with your wallet. - - + title: 'Turtle Club', + button: ( + ), - button: '', + text: `Become a Turtle Club member.`, items: [ { title: 'Source', - status: , - }, - { - title: 'Bolts', - status: '10% of referrals + friends receive 250 Bolts per ETH deposited for 30 days', - }, - { - title: 'Earned', - status: boltsEarnedData['Invite a Friend'] || '-', + status: , }, + { title: 'Multiplier', status: '+10%' }, + { title: 'Status', status: multipliersData['TURTLE_CLUB'] || '-' }, ], }, { @@ -140,79 +146,79 @@ export const MULTIPLIERS = (boltsEarnedData: BoltsEarnedData) => [ Learn more ), - text: `Depositors using a Genesis NFV receive a 10% bonus for all deposit and borrow points earned with that vault.`, + text: `Hold the Genesis NFV.`, items: [ { title: 'Source', status: , }, - { title: 'Bolts', status: '+10% to deposit/borrow' }, - { title: 'Holder', status: boltsEarnedData['OgNFV'] || '-' }, + { title: 'Multiplier', status: '+10%' }, + { title: 'Status', status: multipliersData['GENESIS_NFV'] || '-' }, ], }, { - title: 'ODOG NFT Holder', + title: 'Genesis NFT Holder', button: ( ), text: (
- Holders of the + Hold the - ODOG NFT + Genesis NFT - receive a 3% bonus for all points earned. + {'.'}
), - items: [ - { title: 'Source', status: 'Guild.xyz' }, - { title: 'Bolts', status: '+3% to all points' }, - { title: 'Holder', status: boltsEarnedData['OgNFT'] || '-' }, + { title: 'Source', status: 'NFTs2Me' }, + { title: 'Multiplier', status: '+7%' }, + { title: 'Status', status: multipliersData['GENESIS_NFT'] || '-' }, ], }, { - title: 'Genesis NFT Holder', + title: 'ODOG NFT Holder', button: ( ), text: (
- Holders of the + Hold the - Genesis NFT + ODOG NFT - receive a 7% bonus for all points earned. + {'.'}
), + items: [ - { title: 'Source', status: 'NFTs2Me' }, - { title: 'Bolts', status: '+7% to all points' }, - { title: 'Holder', status: boltsEarnedData['GenesisNFT'] || '-' }, + { title: 'Source', status: 'Guild.xyz' }, + { title: 'Multiplier', status: '+3%' }, + { title: 'Status', status: multipliersData['OG_NFT'] || '-' }, ], }, { @@ -224,8 +230,8 @@ export const MULTIPLIERS = (boltsEarnedData: BoltsEarnedData) => [ title: 'Source', status: , }, - { title: 'Bolts', status: '+30%' }, - { title: 'Earned', status: boltsEarnedData['Community Goal: 20K ETH TVL'] || '-' }, + { title: 'Multiplier', status: '+30% one-time bonus' }, + { title: 'Status', status: multipliersData['ETH_TVL_20K'] || '-' }, ], }, ] @@ -250,7 +256,7 @@ export const QUESTS = (boltsEarnedData: BoltsEarnedData) => [ status: , }, { title: 'Bolts', status: '500 per ETH' }, - { title: 'Earned', status: boltsEarnedData[3] || '-' }, + { title: 'Earned', status: boltsEarnedData['COLLATERAL_DEPOSIT'] || '-' }, ], }, { @@ -270,7 +276,7 @@ export const QUESTS = (boltsEarnedData: BoltsEarnedData) => [ status: , }, { title: 'Bolts', status: '1,000 per ETH' }, - { title: 'Earned', status: boltsEarnedData[1] || '-' }, + { title: 'Earned', status: boltsEarnedData['DEBT_BORROW'] || '-' }, ], }, { @@ -297,7 +303,7 @@ export const QUESTS = (boltsEarnedData: BoltsEarnedData) => [ items: [ { title: 'Source', status: }, { title: 'Bolts', status: '2,000 per ETH' }, - { title: 'Earned', status: boltsEarnedData[8] || '-' }, + { title: 'Earned', status: boltsEarnedData['ODG_ETH_LP'] || '-' }, ], }, { @@ -324,34 +330,53 @@ export const QUESTS = (boltsEarnedData: BoltsEarnedData) => [ items: [ { title: 'Source', status: }, { title: 'Bolts', status: '3,000 per ETH' }, - { title: 'Earned', status: boltsEarnedData[7] || '-' }, + { title: 'Earned', status: boltsEarnedData['OD_ETH_LP'] || '-' }, ], }, { - title: 'Galxe and Zealy', + title: 'Galxe', button: ( <> + + ), + text: 'Complete quests on Galxe.', + items: [ + { + title: 'Source', + status: ( + <> + + + ), + }, + { title: 'Bolts', status: '1 per Point' }, + { title: 'Earned', status: boltsEarnedData['GALXE'] || '-' }, + ], + }, + { + title: 'Zealy', + button: ( + <> ), - text: 'Complete tasks on Galxe and Zealy to earn Bolts.', + text: 'Complete quests on Zealy.', items: [ { title: 'Source', status: ( <> - ), }, - { title: 'Bolts', status: 'Varies' }, - { title: 'Earned', status: boltsEarnedData[6] || '-' }, + { title: 'Bolts', status: '1 per Point' }, + { title: 'Earned', status: boltsEarnedData['ZEALY'] || '-' }, ], }, ] diff --git a/src/containers/Explore/ExploreTable.tsx b/src/containers/Explore/ExploreTable.tsx new file mode 100644 index 00000000..fd509df7 --- /dev/null +++ b/src/containers/Explore/ExploreTable.tsx @@ -0,0 +1,352 @@ +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + getSortedRowModel, + getFilteredRowModel, + SortingState, + ColumnDef, +} from '@tanstack/react-table' +import './index.css' +import styled from 'styled-components' +import Button from '~/components/Button' +import { useState } from 'react' + +type Vault = { + id: string + collateral: string + image?: string | any + collateralAmount: string + debtAmount: string + riskStatus: string + actions?: any +} + +const riskStatusMapping: { [key: string]: number } = { + NO: 0, + LOW: 1, + ELEVATED: 2, + HIGH: 3, + LIQUIDATION: 4, +} + +const parseDebtAmount = (value: string): number => { + return parseFloat(value.replace(/,/g, '').replace(' OD', '')) +} + +const columnHelper = createColumnHelper() +const columns: ColumnDef[] = [ + columnHelper.accessor('id', { + header: () => 'ID', + cell: (info) => info.getValue(), + sortingFn: 'alphanumeric', + enableSorting: true, + }), + columnHelper.accessor('image', { + header: () => '', + cell: (info) => { + const image = info.row.original.image + const vaultID = info.row.original.id + return image ? ( + + + + ) : null + }, + enableSorting: false, + }), + columnHelper.accessor('collateralAmount', { + header: () => 'Collateral Amount', + cell: (info) => info.getValue().toLocaleString(), + sortingFn: (rowA, rowB) => { + const a = rowA.getValue('collateralAmount') + const b = rowB.getValue('collateralAmount') + return a - b + }, + filterFn: (row, columnId, filterValue) => { + const value = row.getValue(columnId) + return value.toString().includes(filterValue) + }, + }), + columnHelper.accessor('collateral', { + header: () => 'Collateral', + cell: (info) => info.getValue(), + sortingFn: 'alphanumeric', + enableSorting: true, + }), + columnHelper.accessor('debtAmount', { + header: () => 'Debt Amount', + cell: (info) => info.getValue(), + sortingFn: (rowA, rowB) => { + const a = parseDebtAmount(rowA.getValue('debtAmount')) + const b = parseDebtAmount(rowB.getValue('debtAmount')) + return a - b + }, + filterFn: (row, columnId, filterValue) => { + const value = parseDebtAmount(row.getValue(columnId)) + return value.toString().includes(filterValue) + }, + }), + columnHelper.accessor('riskStatus', { + header: () => 'Risk Status', + cell: (info) => info.getValue().toLocaleString(), + sortingFn: (rowA, rowB) => { + const a = riskStatusMapping[rowA.getValue('riskStatus')] || 1 + const b = riskStatusMapping[rowB.getValue('riskStatus')] || 1 + return a - b + }, + filterFn: (row, columnId, filterValue) => { + const value = row.getValue(columnId) + return value.includes(filterValue) + }, + }), + columnHelper.accessor('actions', { + header: '', + cell: (info) => { + return ( + + + + ) + }, + enableSorting: false, + }), +] + +const ExploreTable = ({ data }: { data: Vault[] }) => { + const [sorting, setSorting] = useState([]) + const [globalFilter, setGlobalFilter] = useState('') + + const table = useReactTable({ + data: data, + columns, + state: { + sorting, + globalFilter, + }, + onSortingChange: setSorting, + onGlobalFilterChange: setGlobalFilter, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }) + + return ( + + setGlobalFilter(String(e.target.value))} + placeholder="Search all columns..." + style={{ marginBottom: '10px', padding: '8px', width: '100%', fontFamily: 'Barlow' }} + /> + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const header = cell.column.columnDef.header + let headerText = '' + + if (typeof header === 'function') { + // @ts-ignore + const renderedHeader = header(cell.getContext()) + if (typeof renderedHeader === 'string') { + headerText = renderedHeader + } else if ( + typeof renderedHeader === 'object' && + renderedHeader.props && + renderedHeader.props.children + ) { + headerText = renderedHeader.props.children + } + } else { + headerText = header ? header.toString() : '' + } + + return ( + + ) + })} + + ))} + +
+ {header.isPlaceholder ? null : ( + + {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getCanSort() ? ( + header.column.getIsSorted() ? ( + header.column.getIsSorted() === 'asc' ? ( + + ) : ( + + ) + ) : ( +  ⇅ + ) + ) : null} + + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ) +} + +export default ExploreTable + +const ArrowUpAndDownIcon = styled.span` + font-size: 15px; + padding-bottom: 4px; +` + +const StyledArrow = styled.div` + padding-left: 4px; +` + +const SortableHeader = styled.div` + display: flex; + align-items: center; + justify-content: start; + cursor: pointer; + font-family: 'Open Sans', sans-serif; + font-size: ${(props) => props.theme.font.xSmall}; +` + +const TableContainer = styled.div` + overflow-x: auto; + table { + width: 100%; + border-collapse: collapse; + min-width: 600px; + padding: 20px; + background-color: rgba(255, 255, 255, 0); + backdrop-filter: blur(10px); + } + th, + td { + padding: 8px 0px; + text-align: left; + } + + th { + background-color: #fff; + border-top: 2px solid #000; + border-bottom: 2px solid #000; + } + + tr { + margin-bottom: 20px; + } + + tr:not(:last-child) td { + border-bottom: 1px solid #ddd; + } + + @media (max-width: 768px) { + table { + min-width: 100%; + display: block; + overflow-x: auto; + } + + thead { + display: none; + } + + tbody, + td { + display: block; + width: 100%; + box-sizing: border-box; + } + + tr { + display: flex; + flex-direction: column; + margin-bottom: 20px; + border-bottom: 4px solid #ddd; + } + + td { + display: flex; + justify-content: space-between; + padding: 10px; + } + + td::before { + display: flex; + content: attr(data-label); + left: 10px; + white-space: nowrap; + font-weight: bold; + text-align: left; + } + } +` + +const SVGContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 139px; + height: 139px; + position: relative; + margin: 20px 10px 20px 10px; + box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.3), 0 12px 40px 0 rgba(0, 0, 0, 0.25); + + @media (max-width: 768px) { + width: 294px; + height: 294px; + justify-content: center; + margin-left: auto; + margin-right: auto; + } +` + +const SvgWrapper = styled.div` + transform: scale(0.33); + border-radius: 10px; + + @media (max-width: 768px) { + transform: scale(0.7); + } +` + +const ButtonFloat = styled.div` + position: relative; + top: 0; + right: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + gap: 10px; + border-radius: 5px; + z-index: 2; + button { + margin: 5px; + padding: 5px; + } +` diff --git a/src/containers/Explore/index.css b/src/containers/Explore/index.css new file mode 100644 index 00000000..3978e6d9 --- /dev/null +++ b/src/containers/Explore/index.css @@ -0,0 +1,38 @@ +html { + font-family: 'Open Sans', sans-serif; + font-size: 18px; +} + +table { + width: 100%; + background-color: #fff; + border-radius: 4px; + +} + +th { + padding: 20px; + text-align: left; + font-family: 'Barlow', sans-serif; + text-transform: uppercase; + border-bottom: 1px solid #b6c7d3cc; + white-space: nowrap; + + &:first-child { + /* width: 150px; */ + } + + &:not(:first-child) { + /* text-align: right; */ + } +} + +td { + padding: 20px; + text-transform: capitalize; + text-decoration: none; + + &:not(:first-child) { + /* text-align: right; */ + } +} diff --git a/src/containers/Explore/index.tsx b/src/containers/Explore/index.tsx new file mode 100644 index 00000000..f8d64744 --- /dev/null +++ b/src/containers/Explore/index.tsx @@ -0,0 +1,162 @@ +import styled from 'styled-components' +import { useState, useEffect } from 'react' +// @ts-ignore +import { generateSvg } from '@opendollar/svg-generator' +import ExploreTable from './ExploreTable' +import { ethers } from 'ethers' +import { + parseRay, + formatDataNumber, + multiplyRates, + transformToAnnualRate, + calculateRiskStatusText, + ratioChecker, + parseFormattedNumber, +} from '~/utils' +import { AllVaults, useVaultSubgraph } from '~/hooks/useVaultSubgraph' +import useAnalyticsData from '~/hooks/useAnalyticsData' +import useGeb from '~/hooks/useGeb' + +const Explore: React.FC = () => { + const [isLoading, setIsLoading] = useState(true) + const [tableData, setTableData] = useState([]) + const allVaults: AllVaults = useVaultSubgraph() + const geb = useGeb() + const analyticsData = useAnalyticsData() + + const parseNumber = (value: string): number => { + return parseFloat(value.replace(/,/g, '')) + } + + const getSafeData = async () => { + setIsLoading(true) + const tableRows = [] + + if (!allVaults?.vaults || !analyticsData?.tokenAnalyticsData) return + + for (const vault of allVaults.vaults) { + try { + const estimatedValue = `${( + +ethers.utils.formatUnits(vault.collateral) * + +ethers.utils.formatUnits(analyticsData.tokenAnalyticsData[vault.collateralType].currentPrice) + ).toFixed(2)}` + + const stabilityFee = transformToAnnualRate( + multiplyRates( + analyticsData.tokenAnalyticsData[vault.collateralType].stabilityFee.toString(), + analyticsData.redemptionRate?.toString() + ) || '0', + 27 + ) + + const formattedDebt = parseFormattedNumber(formatDataNumber(vault.debt)) + let cratio = 0 + if (formattedDebt !== 0) { + cratio = (+estimatedValue / formattedDebt) * 100 + } + + let correctCollateralizationRatio + if (Number(cratio) > 0) { + correctCollateralizationRatio = Number(cratio) + } else if (Number(cratio) === 0 && parseFormattedNumber(formatDataNumber(vault.collateral)) > 0) { + correctCollateralizationRatio = '∞' + } else if (Number(cratio) === 0 && parseFormattedNumber(formatDataNumber(vault.collateral)) === 0) { + correctCollateralizationRatio = 0 + } + + const svgData = { + vaultID: vault.id, + stabilityFee, + debtAmount: formatDataNumber(vault.debt), + collateralAmount: formatDataNumber(vault.collateral) + ' ' + vault.collateralType, + collateralizationRatio: correctCollateralizationRatio, + liqRatio: Number( + parseRay(analyticsData.tokenAnalyticsData[vault.collateralType].liquidationCRatio) + ), + safetyRatio: Number(parseRay(analyticsData.tokenAnalyticsData[vault.collateralType].safetyCRatio)), + } + + const riskStatus = calculateRiskStatusText( + ratioChecker( + Number(cratio), + Number(parseRay(analyticsData.tokenAnalyticsData[vault.collateralType].liquidationCRatio)), + Number(parseRay(analyticsData.tokenAnalyticsData[vault.collateralType].safetyCRatio)) + ) + ) + + let svg = null + try { + svg = await generateSvg(svgData) + } catch (e) { + console.error(e) + } + + tableRows.push({ + id: vault.id, + collateral: vault.collateralType, + image: svg ? svg : null, + collateralAmount: parseNumber(formatDataNumber(vault.collateral)), + debtAmount: formatDataNumber(vault.debt) + ' OD', + riskStatus: riskStatus, + }) + } catch (e) { + console.error(e) + } + } + // @ts-ignore + setTableData(tableRows) + setIsLoading(false) + } + + useEffect(() => { + getSafeData() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allVaults, analyticsData, geb]) + + return ( + +
+ Explore Vaults +
+ {tableData.length !== 0 && } + {tableData.length === 0 && isLoading &&

Loading...

} + {tableData.length === 0 && !isLoading &&

No vaults available

} +
+ ) +} + +export default Explore + +const Container = styled.div` + max-width: 1362px; + margin: 80px auto; + padding: 0 15px; + @media (max-width: 767px) { + margin: 50px auto; + display: flex; + flex-direction: column; + } + color: ${(props) => props.theme.colors.accent}; +` + +const Title = styled.h2` + font-size: 34px; + font-weight: 700; + font-family: ${(props) => props.theme.family.headers}; + + color: ${(props) => props.theme.colors.accent}; + @media (max-width: 767px) { + font-size: 32px; + } +` + +const Header = styled.div` + display: flex; + justify-content: space-between; + @media (max-width: 767px) { + flex-direction: column; + justify-content: left; + } + + margin-bottom: 40px; +` diff --git a/src/hooks/useFuulSDK.ts b/src/hooks/useFuulSDK.ts deleted file mode 100644 index 343265b2..00000000 --- a/src/hooks/useFuulSDK.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Fuul } from '@fuul/sdk' -import { SiweMessage } from 'siwe' -import { useActiveWeb3React } from '~/hooks/useActiveWeb3React' - -function useFuulSDK() { - const { chainId, provider } = useActiveWeb3React() - - const fuulSendPageViewEvent = async (pageName: string) => { - const response = await Fuul.sendPageview(pageName) - return response - } - - async function createSiweMessage(address: string, statement: string) { - const scheme = window.location.protocol.slice(0, -1) - const domain = window.location.host - const origin = window.location.origin - - const message = new SiweMessage({ - scheme, - domain, - address, - statement, - uri: origin, - version: '1', - chainId: chainId, - }) - return message.prepareMessage() - } - - const sendConnectWalletEvent = async (walletAddress: string): Promise => { - const message = await createSiweMessage( - walletAddress, - `Sign in with Ethereum to Open Dollar and agree to the Terms of Service at opendollar.com/terms and fuul.xyz/terms/users` - ) - const signature = await provider?.getSigner().signMessage(message) - return await Fuul.sendConnectWallet({ - address: walletAddress, - signature, - message, - }) - } - - const createAffiliateCode = async (walletAddress: string, affiliateCode: string): Promise => { - // DO NOT CHANGE THIS MESSAGE - THIS MESSAGE MUST MATCH EXACTLY OR THE SIGNATURE WILL FAIL - const message = `I confirm that I am creating the ${affiliateCode} code on Fuul` - const signature = await provider?.getSigner().signMessage(message) - if (!signature) { - throw new Error('Failed to sign message') - } - return await Fuul.createAffiliateCode(walletAddress, affiliateCode, signature) - } - - const getAffiliateCode = async (walletAddress: string): Promise => { - try { - return await Fuul.getAffiliateCode(walletAddress) - } catch (error) { - console.debug('No affiliate code found:', error) - return null - } - } - - const getUserData = async (user_address: string): Promise => { - try { - const data = await Fuul.getPointsLeaderboard({ user_address }) - const results = data?.results[0] - if (!results) { - return { - points: '0 🔩', - rank: 0, - } - } - return { - points: `${parseInt(results.total_amount).toLocaleString()} 🔩`, - rank: `#${results.rank}`, - } - } catch (error) { - console.debug('No user data found:', error) - return null - } - } - - return { - Fuul, - fuulSendPageViewEvent, - sendConnectWalletEvent, - createAffiliateCode, - getAffiliateCode, - getUserData, - } -} - -export default useFuulSDK diff --git a/src/hooks/useSafeData.ts b/src/hooks/useSafeData.ts index edfa74f4..a284fbe6 100644 --- a/src/hooks/useSafeData.ts +++ b/src/hooks/useSafeData.ts @@ -12,9 +12,23 @@ export default function useSafeData() { const geb = useGeb() const tokensData = geb?.tokenList const { account } = useActiveWeb3React() - const { safeModel: safeActions } = useStoreActions((state) => state) + const { safeModel: safeActions, globalSafeModel: globalSafeActions } = useStoreActions((state) => state) + const previousAccount = usePrevious(account) + const fetchGlobalSafes = useCallback(() => { + if (geb && tokensData) { + try { + globalSafeActions.fetchGlobalSafes({ + geb, + tokensData, + }) + } catch (error) { + console.debug('Failed to fetch user safes', error) + } + } + }, [geb, tokensData, globalSafeActions]) + const fetchUserSafes = useCallback(() => { if (account && geb && tokensData) { try { @@ -32,14 +46,20 @@ export default function useSafeData() { // Fetch safes initially and on account or geb change useEffect(() => { fetchUserSafes() + fetchGlobalSafes() const interval = setInterval(fetchUserSafes, 60000) - return () => clearInterval(interval) - }, [fetchUserSafes]) + const globalInterval = setInterval(fetchGlobalSafes, 60000) + return () => { + clearInterval(interval) + clearInterval(globalInterval) + } + }, [fetchUserSafes, fetchGlobalSafes]) // Handles account changes useEffect(() => { if (account && previousAccount !== account) { fetchUserSafes() + fetchGlobalSafes() } - }, [account, previousAccount, fetchUserSafes]) + }, [account, previousAccount, fetchUserSafes, fetchGlobalSafes]) } diff --git a/src/hooks/useVaultSubgraph.ts b/src/hooks/useVaultSubgraph.ts index 6c2420c3..3c1c63ca 100644 --- a/src/hooks/useVaultSubgraph.ts +++ b/src/hooks/useVaultSubgraph.ts @@ -28,6 +28,13 @@ export type VaultDetails = { collateralType: string } +export type AllVaults = { + vaults: VaultDetails[] + owners: string[] + vaultsByOwner: { [key: string]: string[] } + vaultsByCollateral: { [key: string]: string[] } +} + export const fetchAllVaults = async () => { const data = await postQuery( ` @@ -65,8 +72,12 @@ export const fetchAllVaults = async () => { } export const useVaultSubgraph = () => { - const [vaults, setVaults] = useState({ vaults: [] }) - + const [vaults, setVaults] = useState({ + vaults: [], + owners: [], + vaultsByOwner: {}, + vaultsByCollateral: {}, + }) const getVaults = async () => { const allVaults = await fetchAllVaults() setVaults(allVaults) diff --git a/src/model/boltsModel.ts b/src/model/boltsModel.ts index e4f4ea38..eff8781a 100644 --- a/src/model/boltsModel.ts +++ b/src/model/boltsModel.ts @@ -1,26 +1,25 @@ import { action, Action, thunk, Thunk } from 'easy-peasy' -import { BoltsEarnedData } from '~/containers/Bolts/quests' +import { BoltsEarnedData, MultipliersData } from '~/containers/Bolts/quests' -type Conversion = { - is_referrer: boolean - conversion_id: number - conversion_name: string - total_amount: string +type Campaign = { + type: number + amount: string } export type LeaderboardUser = { rank: number address: string - points: number + bolts: number ens?: string } export interface BoltsModel { - userFuulData: { + userBoltsData: { rank: string - points: string + bolts: string + multiplier: string } - setUserFuulData: Action + setUserBoltsData: Action leaderboardData: LeaderboardUser[] setLeaderboardData: Action @@ -28,6 +27,9 @@ export interface BoltsModel { boltsEarnedData: BoltsEarnedData setBoltsEarnedData: Action + multipliersData: MultipliersData + setMultipliersData: Action + hasFetched: boolean setHasFetched: Action @@ -38,12 +40,13 @@ export interface BoltsModel { } const boltsModel: BoltsModel = { - userFuulData: { + userBoltsData: { rank: '', - points: '', + bolts: '', + multiplier: '', }, - setUserFuulData: action((state, payload) => { - state.userFuulData = payload + setUserBoltsData: action((state, payload) => { + state.userBoltsData = payload }), leaderboardData: [], @@ -56,6 +59,11 @@ const boltsModel: BoltsModel = { state.boltsEarnedData = payload }), + multipliersData: {}, + setMultipliersData: action((state, payload) => { + state.multipliersData = payload + }), + hasFetched: false, setHasFetched: action((state, payload) => { state.hasFetched = payload @@ -68,46 +76,38 @@ const boltsModel: BoltsModel = { fetchData: thunk(async (actions, { account }, { getState }) => { try { - const BOT_DOMAIN = 'https://bot.opendollar.com' + const BOT_DOMAIN = process.env.REACT_APP_OD_API_URL + ? process.env.REACT_APP_OD_API_URL + : 'https://bot.opendollar.com' const BOT_API = `${BOT_DOMAIN}/api/bolts` const response = account ? await fetch(`${BOT_API}?address=${account}`) : await fetch(BOT_API) const result = await response.json() if (result.success) { - const users = result.data.fuul.leaderboard.users + const { leaderboard, user } = result.data const state = getState() // Populate ENS cache for leaderboard users - users.forEach((user: LeaderboardUser) => { + leaderboard.forEach((user: LeaderboardUser) => { const ens = state.ensCache[user.address] || null if (ens) { user.ens = ens } }) - actions.setLeaderboardData(users) - - if (account) { - actions.setUserFuulData(result.data.fuul.user) - + actions.setLeaderboardData(leaderboard) + if (account && user) { const boltsEarned: BoltsEarnedData = {} - const { data } = result - let combinedBorrowBolts = 0 - let combinedDepositBolts = 0 - data.fuul.user.conversions.forEach((conversion: Conversion) => { - if ([1, 2].includes(conversion.conversion_id)) - combinedBorrowBolts += parseInt(conversion.total_amount) - else if ([3, 4].includes(conversion.conversion_id)) - combinedDepositBolts += parseInt(conversion.total_amount) - else boltsEarned[conversion.conversion_id] = parseInt(conversion.total_amount).toLocaleString() + const multipliers: MultipliersData = {} + user.campaigns?.forEach((campaign: Campaign) => { + boltsEarned[campaign.type] = campaign.amount.toLocaleString() + }) + user.multipliers?.forEach((multiplier: Campaign) => { + multipliers[multiplier.type] = parseInt(multiplier.amount) > 0 ? 'Active' : 'Inactive' }) - boltsEarned[1] = combinedBorrowBolts.toLocaleString() - boltsEarned[3] = combinedDepositBolts.toLocaleString() - - boltsEarned['OgNFT'] = data.OgNFT ? 'Yes' : 'No' - boltsEarned['OgNFV'] = data.OgNFV ? 'Yes' : 'No' - boltsEarned['GenesisNFT'] = data.GenesisNFT ? 'Yes' : 'No' + actions.setUserBoltsData(user) actions.setBoltsEarnedData(boltsEarned) + actions.setMultipliersData(multipliers) } } actions.setHasFetched(true) diff --git a/src/model/globalSafeModel.ts b/src/model/globalSafeModel.ts new file mode 100644 index 00000000..695d4b2d --- /dev/null +++ b/src/model/globalSafeModel.ts @@ -0,0 +1,39 @@ +import { action, Action, thunk, Thunk } from 'easy-peasy' +import { StoreModel } from '~/model' +import { fetchGlobalSafes } from '~/services/safes' +import { timeout, ILiquidationData, ISafe, IFetchGlobalSafesPayload } from '~/utils' + +export interface GlobalSafeModel { + list: Array + liquidationData: ILiquidationData | null + fetchGlobalSafes: Thunk + setList: Action> + setLiquidationData: Action +} + +const globalSafeModel: GlobalSafeModel = { + list: [], + liquidationData: null, + fetchGlobalSafes: thunk(async (actions, payload) => { + let fetched + try { + fetched = await fetchGlobalSafes(payload) + } catch (e) { + console.debug('Failed to fetch global safes', e) + } + if (fetched) { + actions.setList(fetched.globalSafes) + actions.setLiquidationData(fetched.liquidationData) + await timeout(200) + return fetched + } + }), + setList: action((state, payload) => { + state.list = payload + }), + setLiquidationData: action((state, payload) => { + state.liquidationData = payload + }), +} + +export default globalSafeModel diff --git a/src/model/index.ts b/src/model/index.ts index 959573ec..8ddf1734 100755 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -2,6 +2,7 @@ import settingsModel, { SettingsModel } from './settingsModel' import popupsModel, { PopupsModel } from './popupsModel' import connectWalletModel, { ConnectWalletModel } from './connectWalletModel' import safeModel, { SafeModel } from './safeModel' +import globalSafeModel, { GlobalSafeModel } from './globalSafeModel' import transactionsModel, { TransactionsModel } from './transactionsModel' import multicallModel, { MulticallModel } from './multicallModel' import auctionModel, { AuctionModel } from './auctionModel' @@ -16,6 +17,7 @@ export interface StoreModel { popupsModel: PopupsModel connectWalletModel: ConnectWalletModel safeModel: SafeModel + globalSafeModel: GlobalSafeModel transactionsModel: TransactionsModel multicallModel: MulticallModel auctionModel: AuctionModel @@ -31,6 +33,7 @@ const model: StoreModel = { popupsModel, connectWalletModel, safeModel, + globalSafeModel, transactionsModel, multicallModel, auctionModel, diff --git a/src/services/checkSanctions.ts b/src/services/checkSanctions.ts index dddf6ec9..fb134d70 100644 --- a/src/services/checkSanctions.ts +++ b/src/services/checkSanctions.ts @@ -3,7 +3,11 @@ import axios from 'axios' async function checkSanctions(address: string) { let res try { - res = await axios.get(`https://bot.opendollar.com/api/screen?address=${address}`, { + const BOT_DOMAIN = process.env.REACT_APP_OD_API_URL + ? process.env.REACT_APP_OD_API_URL + : 'https://bot.opendollar.com' + const BOT_API = `${BOT_DOMAIN}/screen?address=${address}` + res = await axios.get(BOT_API, { headers: { Accept: 'application/json', }, diff --git a/src/services/safes.ts b/src/services/safes.ts index 7c75889d..1c2a12e3 100644 --- a/src/services/safes.ts +++ b/src/services/safes.ts @@ -1,4 +1,4 @@ -import { formatUserSafe, IFetchSafesPayload, IUserSafeList } from '~/utils' +import { formatUserSafe, IFetchGlobalSafesPayload, IFetchSafesPayload, IUserSafeList } from '~/utils' import gebManager from '~/utils/gebManager' export const fetchUserSafes = async (config: IFetchSafesPayload) => { @@ -42,3 +42,26 @@ export const fetchUserSafesRaw = async (config: IFetchSafesPayload) => { return response } + +export const fetchGlobalSafes = async (config: IFetchGlobalSafesPayload) => { + let ownerAddressesResponse = await gebManager.getGlobalSafesRpc() + if (!ownerAddressesResponse) return + + let safesResponse = await gebManager.fetchSafesForOwners(config, ownerAddressesResponse.ownerAddresses) + if (!safesResponse) return + + const liquidationData = { + collateralLiquidationData: safesResponse.collateralLiquidationData, + currentRedemptionPrice: safesResponse.systemState.currentRedemptionPrice.value, + currentRedemptionRate: safesResponse.systemState.currentRedemptionRate.annualizedRate, + globalDebt: safesResponse.systemState.globalDebt, + globalDebtCeiling: safesResponse.systemState.globalDebtCeiling, + perSafeDebtCeiling: safesResponse.systemState.perSafeDebtCeiling, + } + + const globalSafes = formatUserSafe(safesResponse.safes, liquidationData, config.tokensData) + return { + globalSafes, + liquidationData, + } +} diff --git a/src/utils/formatDataNumber.ts b/src/utils/formatDataNumber.ts index 5c7fbb8c..0651d44d 100644 --- a/src/utils/formatDataNumber.ts +++ b/src/utils/formatDataNumber.ts @@ -31,7 +31,9 @@ export function formatDataNumber( return new Intl.NumberFormat('en-US', { minimumFractionDigits: minimumDecimals, maximumFractionDigits: - minimumDecimals >= formatDecimal ? Math.min(minimumDecimals, formatDecimal) + 1 : formatDecimal, + minimumDecimals >= formatDecimal && formatDecimal !== 0 + ? Math.min(minimumDecimals, formatDecimal) + 1 + : formatDecimal, notation: compact ? 'compact' : 'standard', style: currency ? 'currency' : 'decimal', currency: 'USD', diff --git a/src/utils/gebManager/index.ts b/src/utils/gebManager/index.ts index 766c1bd8..8666b1a0 100644 --- a/src/utils/gebManager/index.ts +++ b/src/utils/gebManager/index.ts @@ -1,8 +1,7 @@ +import axios from 'axios' import { BigNumber } from 'ethers' -import { Geb, utils } from '@opendollar/sdk' -import { ILiquidationResponse, IUserSafeList } from '../interfaces' - -import { TokenLiquidationData, fetchLiquidationData } from '@opendollar/sdk/lib/virtual/virtualLiquidationData' +import { fetchLiquidationData, Geb, TokenLiquidationData, utils } from '@opendollar/sdk' +import { ILiquidationResponse, IUserSafeList, IOwnerAddressesResponse } from '../interfaces' import { fetchUserSafes } from '@opendollar/sdk/lib/virtual/virtualUserSafes.js' import { TokenData } from '@opendollar/sdk/lib/contracts/addreses' @@ -14,6 +13,11 @@ interface UserListConfig { safeId_not?: null } +interface GlobalSafesConfig { + geb: Geb + tokensData: { [key: string]: TokenData } +} + // returns LiquidationData const getLiquidationDataRpc = async ( geb: Geb, @@ -98,6 +102,7 @@ const getUserSafesRpc = async (config: UserListConfig): Promise = safeHandler: safe.addy, safeId: safe.id.toString(), collateralType: safe.collateralType, + ownerAddress: config.address, })) return { @@ -111,12 +116,36 @@ const getUserSafesRpc = async (config: UserListConfig): Promise = } } +const getGlobalSafesRpc = async (): Promise => { + const response = await axios.get('https://bot.opendollar.com/api/vaults') + const ownerAddresses: string[] = Array.from(new Set(response.data.details.map((safe: any) => safe.owner))) + return { ownerAddresses } +} + +const fetchSafesForOwners = async (config: GlobalSafesConfig, ownerAddresses: string[]): Promise => { + const allSafes: any[] = [] + const safePromises = ownerAddresses.map((address) => getUserSafesRpc({ ...config, address })) + + const results = await Promise.all(safePromises) + results.forEach((result) => { + allSafes.push(...result.safes) + }) + + const liquidationData = await getLiquidationDataRpc(config.geb, config.tokensData) + return { + safes: allSafes, + erc20Balances: [], + ...liquidationData, + } +} + const gebManager = { getUserSafesRpc, + getGlobalSafesRpc, + fetchSafesForOwners, getLiquidationDataRpc, } -// Helper functions export const parseWad = (val: BigNumber) => utils.wadToFixed(val).toString() export const parseRay = (val: BigNumber) => utils.rayToFixed(val).toString() export const parseRad = (val: BigNumber) => utils.radToFixed(val).toString() diff --git a/src/utils/helper.ts b/src/utils/helper.ts index 34e4cbfb..ef509e36 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -127,6 +127,13 @@ export const toFixedString = (value: string, type: keyof typeof floatsTypes = 'W } } +export const getBytes32String = (collateralType: string, tokensData: { [key: string]: TokenData }): string | null => { + const token = Object.values(tokensData).find( + (token) => token.symbol === collateralType || token.bytes32String === collateralType + ) + return token ? token.bytes32String : null +} + export const formatUserSafe = ( safes: Array, liquidationData: ILiquidationData, @@ -141,18 +148,19 @@ export const formatUserSafe = ( const { currentRedemptionPrice, currentRedemptionRate, collateralLiquidationData } = liquidationData return safes - .filter((s) => s.collateralType in collateralBytes32) .map((s) => { - const token = collateralBytes32[s.collateralType] + const bytes32String = getBytes32String(s.collateralType, tokensData) + if (!bytes32String || !(bytes32String in collateralBytes32)) return null + + const token = collateralBytes32[bytes32String] const accumulatedRate = collateralLiquidationData[token]?.accumulatedRate const currentPrice = collateralLiquidationData[token]?.currentPrice + const availableDebt = returnAvailableDebt(currentPrice?.safetyPrice, '0', s.collateral, s.debt) const liquidationCRatio = collateralLiquidationData[token]?.liquidationCRatio const safetyCRatio = collateralLiquidationData[token]?.safetyCRatio const liquidationPenalty = collateralLiquidationData[token]?.liquidationPenalty const totalAnnualizedStabilityFee = collateralLiquidationData[token]?.totalAnnualizedStabilityFee - const availableDebt = returnAvailableDebt(currentPrice?.safetyPrice, '0', s.collateral, s.debt) - const totalDebt = returnTotalValue(returnTotalDebt(s.debt, accumulatedRate) as string, '0').toString() const liquidationPrice = getLiquidationPrice( @@ -171,12 +179,13 @@ export const formatUserSafe = ( return { id: s.safeId, + ownerAddress: s.ownerAddress, safeHandler: s.safeHandler, date: s.createdAt, riskState: ratioChecker(Number(collateralRatio), Number(liquidationCRatio), Number(safetyCRatio)), collateral: s.collateral, collateralType: s.collateralType, - collateralName: collateralBytes32[s.collateralType], + collateralName: collateralBytes32[bytes32String], debt: s.debt, totalDebt, availableDebt, @@ -192,6 +201,7 @@ export const formatUserSafe = ( currentRedemptionRate: currentRedemptionRate || '0', } as ISafe }) + .filter((s): s is ISafe => s !== null) .sort((a, b) => Number(b.riskState) - Number(a.riskState) || Number(b.debt) - Number(a.debt)) } @@ -245,6 +255,35 @@ export const safeIsSafe = (totalCollateral: string, totalDebt: string, safetyPri return totalDebtBN.lte(totalCollateralBN.mul(safetyPriceBN).div(gebUtils.RAY)) } +/** + * Removes commas from a formatted number + * @param value + */ +export const parseFormattedNumber = (value: string): number => { + return parseFloat(value.replace(/,/g, '')) +} + +/** + * Calculate the risk status text given a numeric risk status + * @param riskStatusNumeric + */ +export const calculateRiskStatusText = (riskStatusNumeric: Number) => { + switch (riskStatusNumeric) { + case 0: + return 'NO' + case 1: + return 'LOW' + case 2: + return 'ELEVATED' + case 3: + return 'HIGH' + case 4: + return 'LIQUIDATION' + default: + return 'LOW' + } +} + /** * Check the risk state of the current liquidation ratio given a fixed minLiquidationRatio * @param currentLiquidationRatio diff --git a/src/utils/index.test.ts b/src/utils/index.test.ts index bd9a8c95..863624da 100644 --- a/src/utils/index.test.ts +++ b/src/utils/index.test.ts @@ -1,3 +1,4 @@ +import { formatDataNumber } from './formatDataNumber' import { formatNumber, getCollateralRatio, @@ -167,4 +168,26 @@ describe('utils', () => { expect(returnTotalValue('2', '2', true, true)).toEqual('0') }) }) + + describe('formatDataNumber', () => { + it('handles format decimals correctly', () => { + const input = '2457565485783295579314012' + expect(formatDataNumber(input, 18, 2)).toEqual('2,457,565.49') + }) + + it('handles dollar currency sign correctly', () => { + const input = '2457565485783295579314012' + expect(formatDataNumber(input, 18, 2, true)).toEqual('$2,457,565.49') + }) + + it('handles compact value and currency sign correctly', () => { + const input = '2457565485783295579314012' + expect(formatDataNumber(input, 18, 2, true, true)).toEqual('$2.46M') + }) + + it('handles compact format decimals and minimum decimals correctly', () => { + const input = '1000000000000000000' + expect(formatDataNumber(input, 18, 3, true, undefined, 4)).toEqual('$1.0000') + }) + }) }) diff --git a/src/utils/interfaces.ts b/src/utils/interfaces.ts index c44ac3c3..98ae62fc 100644 --- a/src/utils/interfaces.ts +++ b/src/utils/interfaces.ts @@ -16,6 +16,10 @@ export interface DynamicObject { [key: string]: any } +export interface IOwnerAddressesResponse { + ownerAddresses: string[] +} + interface IColors { primary: string secondary: string @@ -366,6 +370,11 @@ export interface IFetchSafesPayload { tokensData: { [key: string]: TokenData } } +export interface IFetchGlobalSafesPayload { + geb: Geb + tokensData: { [key: string]: TokenData } +} + export interface IFetchSafeById extends IFetchSafesPayload { safeId: string } diff --git a/yarn.lock b/yarn.lock index a0afed32..595a4588 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2938,16 +2938,6 @@ __metadata: languageName: node linkType: hard -"@fuul/sdk@npm:^4.7.1": - version: 4.7.4 - resolution: "@fuul/sdk@npm:4.7.4" - dependencies: - axios: ^1.2.2 - nanoid: ^4.0.0 - checksum: 0351e5f52733dcc12f1f8f28c75289a36387721b64f7425e2e33c7e6c438e04af56c207e3bded8799c12500a4584be7070e26d559351dbdb8b28072678b16a0f - languageName: node - linkType: hard - "@graphql-typed-document-node/core@npm:^3.1.1": version: 3.2.0 resolution: "@graphql-typed-document-node/core@npm:3.2.0" @@ -3688,15 +3678,15 @@ __metadata: languageName: node linkType: hard -"@opendollar/sdk@npm:1.7.3-rc.1": - version: 1.7.3-rc.1 - resolution: "@opendollar/sdk@npm:1.7.3-rc.1" +"@opendollar/sdk@npm:1.7.4-rc.1": + version: 1.7.4-rc.1 + resolution: "@opendollar/sdk@npm:1.7.4-rc.1" dependencies: "@opendollar/abis": 0.0.0-605371bd ethers: 5.4.7 peerDependencies: utf-8-validate: ^5.0.2 - checksum: 43ad0437424c39a0938fc8994f1469ac9e3f83b3246e2fde7c3d293ecd68ffe11dae8eb60eebaaee7d91fb4182132014e165c7799a4713b504057aaa0246b8ff + checksum: 74974d2ca6af9030f04bb3bfeb692cd2028d83cc1fa606708069fba03bf0f3fdb9a4368dfbfb9f4403f3c4df04ac6bd7ad7d1e48d7a9852afb865dcb10fa2aed languageName: node linkType: hard @@ -7016,7 +7006,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.2.2, axios@npm:^1.6.7": +"axios@npm:^1.6.7": version: 1.7.2 resolution: "axios@npm:1.7.2" dependencies: @@ -14656,15 +14646,6 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^4.0.0": - version: 4.0.2 - resolution: "nanoid@npm:4.0.2" - bin: - nanoid: bin/nanoid.js - checksum: 3fec62f422bc4727918eda0e7aa43e9cbb2e759be72813a0587b9dac99727d3c7ad972efce7f4f1d4cb5c7c554136a1ec3b1043d1d91d28d818d6acbe98200e5 - languageName: node - linkType: hard - "napi-wasm@npm:^1.1.0": version: 1.1.0 resolution: "napi-wasm@npm:1.1.0" @@ -15022,8 +15003,7 @@ __metadata: "@ethersproject/address": ^5.0.10 "@ethersproject/experimental": 5.4.0 "@ethersproject/providers": 5.4.5 - "@fuul/sdk": ^4.7.1 - "@opendollar/sdk": 1.7.3-rc.1 + "@opendollar/sdk": 1.7.4-rc.1 "@opendollar/svg-generator": 1.0.5 "@react-spring/web": ^9.7.3 "@sentry/cli": ^2.31.0