├── client
├── src
│ ├── components
│ │ ├── DetailsBarChart
│ │ │ ├── DetailsBarChart.css
│ │ │ └── DetailsBarChart.js
│ │ ├── DetailsPieChart
│ │ │ ├── DetailsPieChart.css
│ │ │ └── DetailsPieChart.js
│ │ ├── MaxiToggle
│ │ │ ├── MaxiToggle.js
│ │ │ └── MaxiToggle.css
│ │ ├── DropdownButton
│ │ │ ├── DropdownButton.js
│ │ │ └── DropdownButton.css
│ │ ├── Footer
│ │ │ ├── Footer.js
│ │ │ └── Footer.css
│ │ ├── TokenCell
│ │ │ ├── TokenCell.css
│ │ │ └── TokenCell.js
│ │ ├── MiniToggle
│ │ │ ├── MiniToggle.js
│ │ │ └── MiniToggle.css
│ │ ├── InfoModal
│ │ │ ├── InfoModal.js
│ │ │ └── InfoModal.css
│ │ ├── OverViewCard
│ │ │ ├── OverviewCard.js
│ │ │ └── OverviewCard.css
│ │ ├── SummaryTable
│ │ │ ├── SummaryTable.css
│ │ │ └── SummaryTable.js
│ │ ├── LoadingModal
│ │ │ ├── LoadingModal.js
│ │ │ └── LoadingModal.css
│ │ ├── Welcome
│ │ │ ├── Welcome.js
│ │ │ └── Welcome.css
│ │ ├── SummaryBox
│ │ │ ├── SummaryBox.css
│ │ │ └── SummaryBox.js
│ │ ├── DetailsTable
│ │ │ ├── DetailsTable.css
│ │ │ └── DetailsTable.js
│ │ └── Nav
│ │ │ ├── Nav.css
│ │ │ └── Nav.js
│ ├── containers
│ │ ├── App
│ │ │ ├── App.css
│ │ │ ├── AppContext.js
│ │ │ └── App.test.js
│ │ ├── FarmingFieldDetails
│ │ │ ├── FarmingFieldDetails.css
│ │ │ └── FarmingFieldDetails.js
│ │ ├── MyAssets
│ │ │ ├── MyAssets.css
│ │ │ └── MyAssets.js
│ │ ├── TokenDetails
│ │ │ ├── TokenDetails.css
│ │ │ └── TokenDetails.js
│ │ └── EarningFieldDetails
│ │ │ ├── EarningFieldDetails.css
│ │ │ └── EarningFieldDetails.js
│ ├── apis
│ │ ├── coinGecko
│ │ │ ├── supportedCurrencies.js
│ │ │ ├── geckoEndPoints.js
│ │ │ ├── currentPrice.js
│ │ │ ├── getHistoricalPrice.js
│ │ │ └── getTokenPrices.js
│ │ ├── simpleFi
│ │ │ ├── fields.js
│ │ │ ├── index.js
│ │ │ ├── simpleFiEPs.js
│ │ │ ├── etherscan.js
│ │ │ └── tokens.js
│ │ ├── ethereum
│ │ │ ├── ethProvider.js
│ │ │ ├── protocolQueries
│ │ │ │ ├── curveQueries
│ │ │ │ │ ├── curveRawStatsEPs.js
│ │ │ │ │ ├── getCurveGaugeConstants.js
│ │ │ │ │ └── getRawCurvePoolData.js
│ │ │ │ ├── index.js
│ │ │ │ └── uniswapQueries
│ │ │ │ │ ├── getUniswapGraphData.js
│ │ │ │ │ └── uniswapGraphQueryStrings.js
│ │ │ ├── getAPYs
│ │ │ │ ├── earningAPYs
│ │ │ │ │ ├── getCurveEarningAPY.js
│ │ │ │ │ ├── getUniswapEarningAPY.js
│ │ │ │ │ └── getEarningAPYs.js
│ │ │ │ ├── getAPYs.js
│ │ │ │ └── farmingAPYs
│ │ │ │ │ ├── getFarmingAPYs.js
│ │ │ │ │ ├── getSnxFarmingAPY.js
│ │ │ │ │ ├── getSecondaryFieldAPYs.js
│ │ │ │ │ └── getCurveFarmingAPY.js
│ │ │ ├── getTotalFieldSupply.js
│ │ │ ├── index.js
│ │ │ ├── getROIs
│ │ │ │ ├── earningROIs
│ │ │ │ │ ├── getUniswapLiquidityHistory.js
│ │ │ │ │ ├── getUserLiquidityHistory.js
│ │ │ │ │ └── getCurveLiquidityHistory.js
│ │ │ │ ├── farmingROIs
│ │ │ │ │ ├── getUniswapFarmingPriceHistory.js
│ │ │ │ │ ├── getUserFarmingHistory.js
│ │ │ │ │ └── getCurveFarmingPriceHistory.js
│ │ │ │ └── getROIs.js
│ │ │ ├── getUnclaimedRewards.js
│ │ │ ├── balanceContractCreator.js
│ │ │ ├── getUserBalances.js
│ │ │ ├── getFieldSeedReserves.js
│ │ │ └── rewinder.js
│ │ ├── fetchRequest.js
│ │ └── index.js
│ ├── assets
│ │ ├── logos
│ │ │ ├── simplefi-logotype.png
│ │ │ └── simplefi-logotype.svg
│ │ └── icons
│ │ │ ├── caret-down-solid.svg
│ │ │ ├── caret-up-solid.svg
│ │ │ └── external-link.svg
│ ├── helpers
│ │ ├── urlStringSanitiser.js
│ │ ├── detailsChartHelpers
│ │ │ ├── index.js
│ │ │ ├── chartCallbacks.js
│ │ │ ├── detailsBarChartHelper.js
│ │ │ └── detailsPieChartHelper.js
│ │ ├── ethHelpers
│ │ │ ├── ethROIHelpers
│ │ │ │ ├── index.js
│ │ │ │ ├── calcEarningROI.js
│ │ │ │ ├── createWhitelist.js
│ │ │ │ ├── sortLiquidityTxs.js
│ │ │ │ ├── sortFarmingTxs.js
│ │ │ │ └── calcFarmingROI.js
│ │ │ ├── combineFieldSuppliesAndReserves.js
│ │ │ ├── index.js
│ │ │ └── findFieldAddressType.js
│ │ ├── tokenDetailsHelper.js
│ │ ├── earningFieldDetailsHelpers.js
│ │ ├── appHelpers
│ │ │ ├── index.js
│ │ │ ├── populateFieldTokensFromCache.js
│ │ │ ├── addFieldSuppliesAndReserves.js
│ │ │ ├── addFieldInvestmentValues.js
│ │ │ ├── loadingModalHelper.js
│ │ │ └── addLockedAndStakedBalances.js
│ │ ├── myAssetsHelpers
│ │ │ ├── dropdownHelper.js
│ │ │ ├── tokenHelpers.js
│ │ │ └── fieldHelpers.js
│ │ ├── summaryBoxHelper.js
│ │ ├── detailsTableHelper.js
│ │ └── index.js
│ ├── apollo
│ │ ├── index.js
│ │ └── uniswapClient.js
│ ├── data
│ │ ├── pieChartColours.js
│ │ ├── summaryHeaders
│ │ │ ├── earningHeaders.js
│ │ │ ├── farmingHeaders.js
│ │ │ ├── holdingHeaders.js
│ │ │ └── index.js
│ │ ├── infoModalContent
│ │ │ └── infoModalContent.js
│ │ └── fieldData
│ │ │ └── fieldTypes.js
│ ├── setupTests.js
│ ├── index.css
│ ├── index.js
│ ├── CSS
│ │ ├── animations.css
│ │ └── variables.css
│ ├── authentication
│ │ └── web3.js
│ └── serviceWorker.js
├── public
│ ├── robots.txt
│ ├── favicon.ico
│ ├── manifest.json
│ └── index.html
├── .gitignore
├── package.json
└── README.md
├── images
├── logo
│ ├── .DS_Store
│ ├── simplefi-logo.png
│ └── simplefi-logo-transp.png
└── screenshots
│ ├── simplefi-dash.png
│ └── simplefi-splash.png
├── server
├── .gitignore
├── models
│ ├── db.js
│ ├── tokens.js
│ └── fields.js
├── controllers
│ ├── index.js
│ ├── helpers.js
│ ├── userTransactions.js
│ ├── fields.js
│ └── tokens.js
├── router.js
├── apis
│ ├── fetchRequest.js
│ └── userTransactions.js
├── package.json
└── index.js
├── .gitignore
└── README.md
/client/src/components/DetailsBarChart/DetailsBarChart.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/components/DetailsPieChart/DetailsPieChart.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/images/logo/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raphael-mazet/SimpleFi/HEAD/images/logo/.DS_Store
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raphael-mazet/SimpleFi/HEAD/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/src/containers/App/App.css:
--------------------------------------------------------------------------------
1 | .simplefi-app {
2 | min-height: 100vh;
3 | position: relative;
4 | }
--------------------------------------------------------------------------------
/images/logo/simplefi-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raphael-mazet/SimpleFi/HEAD/images/logo/simplefi-logo.png
--------------------------------------------------------------------------------
/images/logo/simplefi-logo-transp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raphael-mazet/SimpleFi/HEAD/images/logo/simplefi-logo-transp.png
--------------------------------------------------------------------------------
/images/screenshots/simplefi-dash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raphael-mazet/SimpleFi/HEAD/images/screenshots/simplefi-dash.png
--------------------------------------------------------------------------------
/images/screenshots/simplefi-splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raphael-mazet/SimpleFi/HEAD/images/screenshots/simplefi-splash.png
--------------------------------------------------------------------------------
/client/src/apis/coinGecko/supportedCurrencies.js:
--------------------------------------------------------------------------------
1 | const supportedCurrencies = ["usd", "eur", "gbp"];
2 |
3 | export default supportedCurrencies;
4 |
--------------------------------------------------------------------------------
/client/src/assets/logos/simplefi-logotype.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raphael-mazet/SimpleFi/HEAD/client/src/assets/logos/simplefi-logotype.png
--------------------------------------------------------------------------------
/client/src/helpers/urlStringSanitiser.js:
--------------------------------------------------------------------------------
1 | export default function urlStringSanitiser(string) {
2 | return string.replace(/[\W_]+/g,"-").toLowerCase();
3 | }
--------------------------------------------------------------------------------
/client/src/apollo/index.js:
--------------------------------------------------------------------------------
1 | import uniswapClient from './uniswapClient';
2 |
3 | //eslint-disable-next-line import/no-anonymous-default-export
4 | export default {
5 | uniswapClient
6 | }
--------------------------------------------------------------------------------
/client/src/containers/App/AppContext.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const AppContext = React.createContext({});
4 |
5 | export const AppProvider = AppContext.Provider;
6 |
7 | export default AppContext;
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 |
4 | # testing
5 | /testing
6 |
7 | # environment
8 |
9 |
10 | # misc
11 | .DS_Store
12 | .env
13 | .env.development
14 | .env.test
15 | .env.production
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # external
2 | .external/
3 |
4 | #vscode
5 | /.vscode
6 |
7 | # production
8 | /config
9 | config/.config.js
10 | solo-project-proposal/
11 |
12 | # misc
13 | .DS_Store
14 | npm-debug.log*
15 | yarn-debug.log*
16 | yarn-error.log*
--------------------------------------------------------------------------------
/server/models/db.js:
--------------------------------------------------------------------------------
1 | const { PrismaClient } = require("@prisma/client");
2 |
3 | const prisma = new PrismaClient({
4 | log: [
5 | {
6 | emit: 'stdout',
7 | level: 'info',
8 | }
9 | ]
10 | });
11 |
12 | module.exports = prisma
--------------------------------------------------------------------------------
/client/src/data/pieChartColours.js:
--------------------------------------------------------------------------------
1 | const pieChartColours = [
2 | '#FACD56',
3 | '#F76284',
4 | '#37A2EB',
5 | '#4BC0C0',
6 | '#57C3E1',
7 | '#F832CC',
8 | '#3F3D56',
9 | '#C9CBCF'
10 | ]
11 |
12 | export default pieChartColours;
13 |
--------------------------------------------------------------------------------
/client/src/apis/simpleFi/fields.js:
--------------------------------------------------------------------------------
1 | import fetchRequest from '../fetchRequest';
2 | import {baseUrl, fieldEP} from './simpleFiEPs';
3 |
4 | function getFields () {
5 | return fetchRequest(baseUrl + fieldEP);
6 | }
7 |
8 | export {
9 | getFields,
10 | }
--------------------------------------------------------------------------------
/client/src/apollo/uniswapClient.js:
--------------------------------------------------------------------------------
1 | import { ApolloClient, InMemoryCache } from '@apollo/client';
2 |
3 | const uniswapClient = new ApolloClient({
4 | uri: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v2',
5 | cache: new InMemoryCache()
6 | });
7 |
8 | export default uniswapClient;
--------------------------------------------------------------------------------
/client/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------
/client/src/apis/simpleFi/index.js:
--------------------------------------------------------------------------------
1 | import { getTokens, getUserFieldTokens } from './tokens';
2 | import { getFields } from './fields';
3 | import { getUserTransactions } from './etherscan';
4 |
5 | export {
6 | getTokens,
7 | getUserFieldTokens,
8 | getFields,
9 | getUserTransactions
10 | }
--------------------------------------------------------------------------------
/client/src/data/summaryHeaders/earningHeaders.js:
--------------------------------------------------------------------------------
1 | const earningHeaders = [
2 | 'Name',
3 | 'Invested ($)',
4 | 'Staked',
5 | 'ROI',
6 | 'APY'
7 | ];
8 |
9 | const earningCurrencyCells = [false, true, false, false, false]
10 |
11 | export {
12 | earningHeaders,
13 | earningCurrencyCells
14 | }
--------------------------------------------------------------------------------
/client/src/data/summaryHeaders/farmingHeaders.js:
--------------------------------------------------------------------------------
1 | const farmingHeaders = [
2 | 'Name',
3 | 'Invested ($)',
4 | 'Farming',
5 | 'ROI',
6 | 'APY'
7 | ];
8 |
9 | const farmingCurrencyCells = [false, true, false, false, false];
10 |
11 | export {
12 | farmingHeaders,
13 | farmingCurrencyCells
14 | }
--------------------------------------------------------------------------------
/client/src/data/summaryHeaders/holdingHeaders.js:
--------------------------------------------------------------------------------
1 | const holdingHeaders = [
2 | 'Token',
3 | 'Amount',
4 | 'Locked',
5 | 'Curr. price',
6 | 'Value'
7 | ]
8 |
9 | const holdingCurrencyCells = [false, false, false, true, true]
10 |
11 | export {
12 | holdingHeaders,
13 | holdingCurrencyCells
14 | }
15 |
--------------------------------------------------------------------------------
/client/src/containers/App/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 | import App from './App';
4 |
5 | test('renders learn react link', () => {
6 | const { getByText } = render( );
7 | const linkElement = getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
--------------------------------------------------------------------------------
/client/src/apis/simpleFi/simpleFiEPs.js:
--------------------------------------------------------------------------------
1 | const baseUrl = 'http://localhost:3020';
2 | const fieldEP = '/fields';
3 | const tokensEP = '/tokens';
4 | const fieldTokensEP = '/userfieldtokens';
5 | const userTxEP = '/userTransactions';
6 |
7 | export {
8 | baseUrl,
9 | fieldEP,
10 | tokensEP,
11 | fieldTokensEP,
12 | userTxEP
13 | }
--------------------------------------------------------------------------------
/client/src/helpers/detailsChartHelpers/index.js:
--------------------------------------------------------------------------------
1 | import extractDetailsPieChartValues from './detailsPieChartHelper';
2 | import extractDetailsBarChartValues from './detailsBarChartHelper';
3 | import chartCallbacks from './chartCallbacks';
4 |
5 | export {
6 | extractDetailsPieChartValues,
7 | extractDetailsBarChartValues,
8 | chartCallbacks
9 | }
--------------------------------------------------------------------------------
/client/src/components/MaxiToggle/MaxiToggle.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './MaxiToggle.css';
3 |
4 | export default function MaxiToggle({handleChange}) {
5 | return(
6 |
7 | handleChange(e)}>
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/client/src/components/DropdownButton/DropdownButton.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './DropdownButton.css';
3 |
4 | export default function DropdownButton({handleDropdown, tableRef}) {
5 | return (
6 |
7 | handleDropdown(e, tableRef)}>
8 |
9 | )
10 | }
--------------------------------------------------------------------------------
/client/src/components/Footer/Footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './Footer.css';
3 |
4 | export default function Footer () {
5 | return (
6 |
7 | © {new Date().getFullYear()} SimpleFi
8 | ·
9 | Twitter
10 |
11 | )
12 | }
--------------------------------------------------------------------------------
/server/controllers/index.js:
--------------------------------------------------------------------------------
1 | const {getTokens, getUserFieldTokens } = require('./tokens');
2 | const { getFields, getFieldWithReceiptToken } = require('./fields');
3 | const { getUserTransactions } = require('./userTransactions');
4 |
5 | module.exports = {
6 | getTokens,
7 | getUserFieldTokens,
8 | getFields,
9 | getFieldWithReceiptToken,
10 | getUserTransactions
11 | }
--------------------------------------------------------------------------------
/client/src/components/TokenCell/TokenCell.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 |
3 | .cell {
4 | padding-left: 10px;
5 | }
6 |
7 | .cell-header {
8 | font-size: 14px;
9 | text-align: left;
10 | }
11 |
12 | .cell-value {
13 | color: var(--dark-grey-font);
14 | font-size: 16px;
15 | font-weight: 700;
16 | }
17 |
18 | .cell-currency:before {
19 | content: '$';
20 | }
--------------------------------------------------------------------------------
/server/controllers/helpers.js:
--------------------------------------------------------------------------------
1 | //TODO: probably need to use an input validator here for security if pure SQL
2 |
3 | function generateFieldTokenQuery (tokenIds) {
4 | let queryArr = [];
5 | for (let prop in tokenIds) {
6 | if (tokenIds[prop]) queryArr.push(tokenIds[prop])
7 | }
8 | return queryArr;
9 | }
10 |
11 | module.exports = {
12 | generateFieldTokenQuery,
13 | }
--------------------------------------------------------------------------------
/client/src/assets/icons/caret-down-solid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/apis/ethereum/ethProvider.js:
--------------------------------------------------------------------------------
1 | import { ethers } from 'ethers';
2 |
3 | //TODO: potentially add default provider
4 | let provider;
5 | if (window.ethereum && window.ethereum.isMetaMask) {
6 | provider = new ethers.providers.Web3Provider(window.ethereum);
7 | provider.getNetwork()
8 | .then(res => console.log(' ---> checkNetwork', res))
9 | }
10 |
11 | export default provider;
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/client/src/components/MiniToggle/MiniToggle.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './MiniToggle.css';
3 |
4 | export default function MiniToggle({handleChange, before, after}) {
5 | return(
6 |
7 |
{before}
8 |
9 |
{after}
10 |
11 | )
12 | }
--------------------------------------------------------------------------------
/client/src/apis/simpleFi/etherscan.js:
--------------------------------------------------------------------------------
1 | import fetchRequest from '../fetchRequest';
2 | import {baseUrl, userTxEP} from './simpleFiEPs';
3 | // TODO: add this requirement to Github documentation
4 |
5 | async function getUserTransactions(address){
6 | const userTransactions = await fetchRequest(baseUrl + userTxEP + '/' + address);
7 | return userTransactions;
8 | }
9 |
10 | export {
11 | getUserTransactions,
12 | }
--------------------------------------------------------------------------------
/client/src/assets/icons/caret-up-solid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/apis/ethereum/protocolQueries/curveQueries/curveRawStatsEPs.js:
--------------------------------------------------------------------------------
1 | //eslint-disable-next-line import/no-anonymous-default-export
2 | export default {
3 | curveMainEP: 'https://www.curve.fi/raw-stats/',
4 | apyEP: 'apys.json',
5 | indivPoolEPs: {
6 | 'Curve: sUSD v2 pool': 'susd',
7 | 'Curve: sBTC pool': 'rens',
8 | 'Curve: hBTC pool': 'hbtc'
9 | },
10 | indivPoolConcat: '-1440m.json'
11 | }
12 |
--------------------------------------------------------------------------------
/client/src/data/summaryHeaders/index.js:
--------------------------------------------------------------------------------
1 | import { holdingHeaders, holdingCurrencyCells } from './holdingHeaders';
2 | import {farmingHeaders, farmingCurrencyCells} from './farmingHeaders';
3 | import { earningHeaders, earningCurrencyCells } from './earningHeaders';
4 |
5 | export {
6 | holdingHeaders,
7 | holdingCurrencyCells,
8 | farmingHeaders,
9 | farmingCurrencyCells,
10 | earningHeaders,
11 | earningCurrencyCells
12 | }
--------------------------------------------------------------------------------
/client/src/components/Footer/Footer.css:
--------------------------------------------------------------------------------
1 | footer {
2 | position: absolute;
3 | bottom: 0;
4 | left: 40%;
5 | padding-bottom: 10px;
6 | text-align: center;
7 | line-height: 24px;
8 | color: darkgrey;
9 | }
10 |
11 | footer span, footer a {
12 | margin-left: 1px;
13 | margin-right: 1px;
14 | font-size: 12px;
15 | }
16 |
17 | footer a {
18 | color: darkgrey;
19 | text-decoration: underline;
20 | cursor: pointer;
21 | }
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | @import "./CSS/variables.css";
2 |
3 | * {
4 | box-sizing: border-box;
5 | }
6 |
7 | body {
8 | all: unset;
9 | background-color: var(--lilac);
10 | font-family: -apple-system,system-ui,BlinkMacSystemFont,Segoe UI,Roboto,Noto Sans,Ubuntu,Droid Sans,Helvetica Neue,sans-serif;
11 | position: relative;
12 | }
13 |
14 | a, button {
15 | all: unset;
16 | }
17 |
18 | h1, h2, h3, p {
19 | all: unset;
20 | }
--------------------------------------------------------------------------------
/client/src/data/infoModalContent/infoModalContent.js:
--------------------------------------------------------------------------------
1 | export default function infoModalContent(content) {
2 | let modalText;
3 | switch(content) {
4 | case 'about':
5 | modalText = 'SimpleFi is a tool to manage decentralised financial investments. It\'s free and open source. \nPlease send your feedback to feedback@simplefi.finance.';
6 | break;
7 | default:
8 | modalText = '';
9 | }
10 | return modalText;
11 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getAPYs/earningAPYs/getCurveEarningAPY.js:
--------------------------------------------------------------------------------
1 | import { getAllCurvePoolRawAPY, curveEPs } from '../../protocolQueries'
2 |
3 | const { indivPoolEPs } = curveEPs
4 |
5 | async function getCurveEarningAPY (field) {
6 | const { name } = field;
7 | const latestAPYs = await getAllCurvePoolRawAPY();
8 | const epKey = indivPoolEPs[name];
9 | return latestAPYs.apy.day[epKey];
10 | }
11 |
12 | export default getCurveEarningAPY;
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 | .VSCodeCounter
22 |
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
--------------------------------------------------------------------------------
/client/src/helpers/ethHelpers/ethROIHelpers/index.js:
--------------------------------------------------------------------------------
1 | import calcFarmingROI from './calcFarmingROI';
2 | import calcEarningROI from './calcEarningROI';
3 | import createWhitelist from './createWhitelist';
4 | import sortLiquidityTxs from './sortLiquidityTxs';
5 | import sortFarmingTxs from './sortFarmingTxs';
6 |
7 | export {
8 | calcFarmingROI,
9 | calcEarningROI,
10 | createWhitelist,
11 | sortLiquidityTxs,
12 | sortFarmingTxs
13 | }
14 |
15 |
--------------------------------------------------------------------------------
/client/src/apis/fetchRequest.js:
--------------------------------------------------------------------------------
1 |
2 | export default async function fetchRequest (path, options) {
3 | const response = await fetch(path, options)
4 | .then(res => res.status <= 400 ? res : Promise.reject(res))
5 | .then(res => res.status !== 204 ? res.json() : res)
6 | .catch(err => {
7 | console.error(`Error fetching [${options ? options.method : 'GET'}] ${path}`);
8 | console.error('Error', err);
9 | })
10 | return response;
11 | }
--------------------------------------------------------------------------------
/client/src/apis/coinGecko/geckoEndPoints.js:
--------------------------------------------------------------------------------
1 | import supportedCurrencies from './supportedCurrencies';
2 |
3 | const baseUrl = 'https://api.coingecko.com/api/v3';
4 | const priceEP = '/coins/';
5 | const manyPriceEP = '/simple/price?ids=';
6 | const currencyString = "&vs_currencies=" + supportedCurrencies.join('%2C');
7 | const history = '/history?date=';
8 |
9 | export {
10 | baseUrl,
11 | priceEP,
12 | manyPriceEP,
13 | currencyString,
14 | history
15 | }
--------------------------------------------------------------------------------
/client/src/apis/simpleFi/tokens.js:
--------------------------------------------------------------------------------
1 | import fetchRequest from '../fetchRequest';
2 | import {baseUrl, tokensEP, fieldTokensEP} from './simpleFiEPs';
3 |
4 | function getTokens () {
5 | return fetchRequest(baseUrl + tokensEP);
6 | }
7 |
8 | async function getUserFieldTokens (tokenIds) {
9 | tokenIds = JSON.stringify(tokenIds)
10 | return await fetchRequest(`${baseUrl}${fieldTokensEP}/${tokenIds}`)
11 | }
12 |
13 | export {
14 | getTokens,
15 | getUserFieldTokens
16 | }
--------------------------------------------------------------------------------
/server/router.js:
--------------------------------------------------------------------------------
1 | const router = require('express').Router();
2 | const controllers = require('./controllers');
3 |
4 | router.get('/tokens', controllers.getTokens);
5 | router.get('/fields', controllers.getFields);
6 | router.get('/userfieldtokens/:tokenIds', controllers.getUserFieldTokens);
7 | router.get('/findfield/:receiptToken', controllers.getFieldWithReceiptToken);
8 | router.get('/usertransactions/:address', controllers.getUserTransactions);
9 |
10 | module.exports = router;
--------------------------------------------------------------------------------
/client/src/components/TokenCell/TokenCell.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './TokenCell.css';
3 |
4 | export default function TokenCell ( {header, content, index, currencyCells} ) {
5 | const cellMarkup = header ? (
6 |
7 | {content}
8 |
9 | ) : (
10 |
11 | {content}
12 |
13 | )
14 |
15 | return (cellMarkup);
16 |
17 | }
--------------------------------------------------------------------------------
/client/src/helpers/ethHelpers/combineFieldSuppliesAndReserves.js:
--------------------------------------------------------------------------------
1 | function combineFieldSuppliesAndReserves (supplies, reserves) {
2 | let combinedBalances = [...supplies];
3 |
4 | for (let supply of combinedBalances) {
5 | const findFieldReserves = reserves.filter(reserve => reserve.fieldName === supply.fieldName)[0];
6 | supply.seedReserves = findFieldReserves.seedReserves;
7 | }
8 |
9 | return combinedBalances;
10 | }
11 |
12 | export default combineFieldSuppliesAndReserves;
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import './index.css';
5 | import App from '../src/containers/App/App';
6 | import * as serviceWorker from './serviceWorker';
7 |
8 | ReactDOM.render(
9 |
10 |
11 |
12 |
13 | ,
14 | document.getElementById('root')
15 | );
16 |
17 | serviceWorker.unregister();
18 |
--------------------------------------------------------------------------------
/server/apis/fetchRequest.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch');
2 |
3 | async function fetchRequest (path, options) {
4 | const response = await fetch(path, options)
5 | .then(res => res.status <= 400 ? res : Promise.reject(res))
6 | .then(res => res.status !== 204 ? res.json() : res)
7 | .catch(err => {
8 | console.error(`Error fetching [${options ? options.method : 'GET'}] ${path}`);
9 | console.error('Error', err);
10 | })
11 | return response;
12 | }
13 |
14 | module.exports = fetchRequest;
--------------------------------------------------------------------------------
/client/src/helpers/ethHelpers/index.js:
--------------------------------------------------------------------------------
1 | import findFieldAddressType from './findFieldAddressType';
2 | import combineFieldSuppliesAndReserves from './combineFieldSuppliesAndReserves';
3 | import {
4 | sortLiquidityTxs,
5 | sortFarmingTxs,
6 | createWhitelist,
7 | calcEarningROI,
8 | calcFarmingROI
9 | } from './ethROIHelpers';
10 |
11 |
12 | export {
13 | findFieldAddressType,
14 | combineFieldSuppliesAndReserves,
15 | sortLiquidityTxs,
16 | sortFarmingTxs,
17 | createWhitelist,
18 | calcEarningROI,
19 | calcFarmingROI
20 | }
--------------------------------------------------------------------------------
/client/src/helpers/tokenDetailsHelper.js:
--------------------------------------------------------------------------------
1 | function extractTotalTokenBalance(token) {
2 | const unlockedBalance = token.userBalance ? token.userBalance : 0;
3 | const lockedBalance = token.lockedBalance ? token.lockedBalance.reduce((acc, lockedBalance) => acc + lockedBalance.balance, 0) : 0;
4 | const unclaimedBalance = token.unclaimedBalance ? token.unclaimedBalance.reduce((acc, unclaimedBalance) => acc + unclaimedBalance.balance, 0) : 0;
5 | return unlockedBalance + lockedBalance + unclaimedBalance;
6 | }
7 |
8 | export {
9 | extractTotalTokenBalance
10 | }
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "jest"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "dependencies": {
13 | "@prisma/client": "^2.10.2",
14 | "cors": "^2.8.5",
15 | "dotenv": "^8.2.0",
16 | "express": "^4.17.1",
17 | "mongoose": "^5.10.3",
18 | "morgan": "^1.10.0",
19 | "node-fetch": "^2.6.1",
20 | "pg": "^8.3.3"
21 | },
22 | "devDependencies": {
23 | "@prisma/cli": "^2.10.0"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/components/InfoModal/InfoModal.js:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useState} from 'react';
2 | import infoModalContent from '../../data/infoModalContent/infoModalContent';
3 | import './InfoModal.css';
4 |
5 | export default function InfoModal({content, contentRef}) {
6 | const [modalContent, setModalContent] = useState(infoModalContent(content));
7 |
8 | useEffect(() => {
9 | setModalContent(infoModalContent(content))
10 | }, [content])
11 |
12 | return (
13 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/helpers/earningFieldDetailsHelpers.js:
--------------------------------------------------------------------------------
1 | export default function calcCombinedROI(combinedFields) {
2 | const {earningField, farmingFields} = combinedFields;
3 | const investmentValue = earningField.earningROI.histInvestmentValue;
4 | const earningReturnValue = earningField.earningROI.absReturnValue;
5 | const farmingReturnValue = farmingFields.reduce((acc, curr) => acc + curr.farmingROI.absReturnValue, 0);
6 | const absReturnValue = earningReturnValue + farmingReturnValue;
7 | const combinedROI = absReturnValue / investmentValue;
8 |
9 | return ({roi: combinedROI, abs: absReturnValue})
10 | }
--------------------------------------------------------------------------------
/client/src/components/InfoModal/InfoModal.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 |
3 | .info-modal {
4 | display: flex;
5 | position: fixed;
6 | top: 80px;
7 | left: 40%;
8 | overflow: auto;
9 | background-color: var(--transp-offwhite);
10 | color: var(--dark-grey-font);
11 | flex-direction: column;
12 | box-shadow: 5px 5px 10px 0px var(--periwinkle);
13 | max-width: 400px;
14 | height: 40%;
15 | border-radius: 20px;
16 | }
17 |
18 | .info-modal p {
19 | padding: 30px 50px 0 50px;
20 | color: var(--darkest-grey-font);
21 | font-size: 1.2em;
22 | line-height: 2em;
23 | white-space: pre-wrap;
24 | }
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = require('./router');
3 | const cors = require('cors');
4 | const morgan = require('morgan');
5 | const path = require('path')
6 |
7 | require('dotenv').config()
8 |
9 | const app = express();
10 | const port = process.env.PORT || 3020;
11 |
12 | const corsConfig = {
13 | origin: 'http://localhost:3000',
14 | credentials: true,
15 | };
16 |
17 | app.use(morgan('tiny'));
18 | app.use(express.json());
19 | app.use(cors(corsConfig));
20 | app.use(router);
21 |
22 | app.listen(port, () => {
23 | console.log(`Solo server listening on localhost:${port} 🎉`)
24 | });
--------------------------------------------------------------------------------
/client/src/CSS/animations.css:
--------------------------------------------------------------------------------
1 | @keyframes growDown {
2 | 0% {
3 | transform: scaleY(0)
4 | }
5 | 100% {
6 | transform: scaleY(1)
7 | }
8 | }
9 |
10 | @keyframes shrinkUp {
11 | 0% {
12 | transform: scaleY(1)
13 | }
14 | 100% {
15 | transform: scaleY(0)
16 | }
17 | }
18 |
19 | @keyframes pulse {
20 | 0% {
21 | background-color: #CCCCFF33;
22 | box-shadow: 0 0 0 0 #CCCCFF66;
23 | border-radius: 50px;
24 | }
25 | 70% {
26 | box-shadow: 0 0 40px 20px #CCCCFF00;
27 | border-radius: 50px;
28 | }
29 | 100% {
30 | box-shadow: 0 0 0 0 #CCCCFF00;
31 | border-radius: 50px;
32 | }
33 | }
--------------------------------------------------------------------------------
/client/src/helpers/appHelpers/index.js:
--------------------------------------------------------------------------------
1 | import populateFieldTokensFromCache from './populateFieldTokensFromCache';
2 | import addFieldSuppliesAndReserves from './addFieldSuppliesAndReserves';
3 | import { addUnclaimedBalances, addLockedTokenBalances, addStakedFieldBalances } from './addLockedAndStakedBalances';
4 | import addFieldInvestmentValues from './addFieldInvestmentValues';
5 | import amendModal from './loadingModalHelper';
6 |
7 | export {
8 | populateFieldTokensFromCache,
9 | addFieldSuppliesAndReserves,
10 | addUnclaimedBalances,
11 | addLockedTokenBalances,
12 | addStakedFieldBalances,
13 | addFieldInvestmentValues,
14 | amendModal
15 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/protocolQueries/index.js:
--------------------------------------------------------------------------------
1 | import {
2 | getUniswapPoolVolume,
3 | getUniswapBalanceHistory
4 | } from './uniswapQueries/getUniswapGraphData';
5 | import uniswapQueries from './uniswapQueries/uniswapGraphQueryStrings';
6 | import {
7 | getOneCurvePoolRawData,
8 | getAllCurvePoolRawAPY
9 | } from './curveQueries/getRawCurvePoolData';
10 | import curveEPs from './curveQueries/curveRawStatsEPs'
11 |
12 | //eslint-disable-next-line import/no-anonymous-default-export
13 | export {
14 | getUniswapPoolVolume,
15 | getUniswapBalanceHistory,
16 | uniswapQueries,
17 | getOneCurvePoolRawData,
18 | getAllCurvePoolRawAPY,
19 | curveEPs
20 | }
--------------------------------------------------------------------------------
/client/src/helpers/myAssetsHelpers/dropdownHelper.js:
--------------------------------------------------------------------------------
1 | export default function toggleDropdown(e, tableRef) {
2 | e.preventDefault();
3 | const table = tableRef.current.style;
4 | const button = e.target.classList;
5 | if (table?.display === '' || table?.display === 'none') {
6 | table.display = 'block';
7 | table.animation = 'growDown 300ms ease-in-out forwards';
8 | button.add('dropdown-active');
9 | button.remove('dropdown');
10 | } else {
11 | table.animation = 'shrinkUp 300ms ease-in-out forwards';
12 | setTimeout(() => table.display = 'none', 300);
13 | button.remove('dropdown-active');
14 | button.add('dropdown');
15 | }
16 | }
--------------------------------------------------------------------------------
/server/controllers/userTransactions.js:
--------------------------------------------------------------------------------
1 | const Apis = require('../apis/userTransactions');
2 |
3 | async function getUserTransactions(req, res) {
4 | try {
5 | const address = req.params.address;
6 | const userTokenTransactions = await Apis.getUserTokenTransactions(address);
7 | const userNormalTransactions = await Apis.getUserNormalTransactions(address);
8 | res.status = 200;
9 | res.send({userTokenTransactions, userNormalTransactions});
10 | } catch (err) {
11 | console.error(`Error at ${path.basename(__dirname)}/${path.basename(__filename)} ${err}`);
12 | res.sendStatus(500);
13 | }
14 | }
15 |
16 | module.exports = {
17 | getUserTransactions
18 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getTotalFieldSupply.js:
--------------------------------------------------------------------------------
1 | const ethers = require('ethers');
2 |
3 | async function getTotalFieldSupply (fieldName, contract, decimals, cache) {
4 |
5 |
6 | const findSupplyInCache = cache.filter(fieldWithSupply => fieldWithSupply.fieldName === fieldName)[0];
7 |
8 | let totalFieldSupply;
9 | if (findSupplyInCache) {
10 | totalFieldSupply = findSupplyInCache.totalFieldSupply;
11 | } else {
12 | const bigIntSupply = await contract.totalSupply();
13 | totalFieldSupply = Number(ethers.utils.formatUnits(bigIntSupply, decimals));
14 | cache.push({
15 | fieldName,
16 | totalFieldSupply
17 | });
18 | }
19 | return totalFieldSupply;
20 | }
21 |
22 | export default getTotalFieldSupply;
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getAPYs/getAPYs.js:
--------------------------------------------------------------------------------
1 | import getFarmingAPYs from './farmingAPYs/getFarmingAPYs';
2 | import getEarningAPYs from './earningAPYs/getEarningAPYs';
3 |
4 | async function getAPYs (userFields, userTokens, userTokenPrices) {
5 |
6 | const fieldsWithAPYs = [...userFields];
7 |
8 | for (let field of fieldsWithAPYs) {
9 |
10 | if (field.cropTokens.length) {
11 | //@dev: farmingAPY is either a number or {combinedAPY, secondaryAPYs}
12 | field.farmingAPY = await getFarmingAPYs(field, userTokenPrices);
13 | }
14 |
15 | if (field.isEarning) {
16 | field.earningAPY = await getEarningAPYs(field, userTokens, userTokenPrices);
17 | }
18 | }
19 |
20 | return fieldsWithAPYs;
21 | }
22 |
23 | export default getAPYs;
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 | SimpleFi
15 |
16 |
17 |
18 | You need to enable JavaScript to run this app.
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/client/src/data/fieldData/fieldTypes.js:
--------------------------------------------------------------------------------
1 | //TODO: add documentation
2 | const fieldInterfaceTypes = {
3 | curveSwap: [
4 | {
5 | name: "curve swap 4 (sUSD)",
6 | ciId: "8977922f-c0fe-4654-b2e1-d509c65c8273"
7 | },
8 | {
9 | name: "curve swap 3 (sBTC)",
10 | ciId: "c95d2eeb-781a-4ce8-a4d1-35aeaec2953d"
11 | }
12 | ],
13 | curveSNX: [
14 | {
15 | name: "Curve: sUSD reward gauge",
16 | ciId: "d39fcad8-ca2b-4add-87d9-50043ee34bd4"
17 | },
18 | {
19 | name: "Curve: sBTC reward gauge",
20 | ciId: "e7415498-1411-4b6b-bf69-aa9d61ea2e15"
21 | },
22 | ],
23 | uniswap: [
24 | {
25 | name: "uniswap V2 earn",
26 | ciId: "8718740f-6d08-4482-9a0c-c0cb2561f01c"
27 | }
28 | ],
29 | }
30 |
31 | export default fieldInterfaceTypes;
--------------------------------------------------------------------------------
/client/src/helpers/appHelpers/populateFieldTokensFromCache.js:
--------------------------------------------------------------------------------
1 | function populateFieldTokensFromCache (fieldsWithBalance, trackedTokens) {
2 | fieldsWithBalance = fieldsWithBalance.map(field => {
3 |
4 | field.cropTokens = field.cropTokens.map(token => {
5 | //TODO: do not delete unclaimed balance method
6 | return trackedTokens.find(trackedToken => token.tokenId === trackedToken.tokenId)
7 | });
8 |
9 | field.seedTokens = field.seedTokens.map(token => {
10 | const { seedIndex } = token;
11 | return {
12 | ...trackedTokens.find(trackedToken => token.tokenId === trackedToken.tokenId),
13 | seedIndex
14 | }
15 | });
16 |
17 | return field;
18 | })
19 |
20 | return fieldsWithBalance;
21 | }
22 |
23 | export default populateFieldTokensFromCache;
--------------------------------------------------------------------------------
/client/src/apis/coinGecko/currentPrice.js:
--------------------------------------------------------------------------------
1 | import fetchRequest from '../fetchRequest';
2 | import supportedCurrencies from './supportedCurrencies'
3 |
4 | const baseUrl = 'https://api.coingecko.com/api/v3';
5 | const priceEP = '/coins/';
6 | const manyPriceEP = '/simple/price?ids=';
7 | const currencyString = "&vs_currencies=" + supportedCurrencies.join('%2C');
8 |
9 | function currentPrice (tokenId) {
10 | return fetchRequest(baseUrl + priceEP + tokenId)
11 | .then(token => token.market_data.current_price.usd)
12 | }
13 |
14 | function manyPrices (tokenIds) {
15 | tokenIds = tokenIds.replace(/,/g, '%2C')
16 | return fetchRequest(baseUrl + manyPriceEP + tokenIds + currencyString);
17 | }
18 |
19 | //eslint-disable-next-line import/no-anonymous-default-export
20 | export default {
21 | currentPrice,
22 | manyPrices
23 | }
--------------------------------------------------------------------------------
/client/src/CSS/variables.css:
--------------------------------------------------------------------------------
1 | :root {
2 | /* colours */
3 | --lilac: #8B5D9D;
4 | --bright-lilac: #9953A3;
5 | --transp-bright-lilac: #9953A3AE;
6 |
7 | --modal-dot: #9880ff;
8 | --opaque-modal-dot: #9880ffCE;
9 | --success-green: #43A047;
10 |
11 | --periwinkle: #BBB3E8;
12 | --bright-periwinkle: #CCCCFF;
13 | --sky-blue: rgb(87, 195, 225);
14 |
15 | --offwhite: #FFF0F5;
16 | --transp-offwhite: #FFF0F5CE;
17 | --almost-white: #FFFAFA;
18 |
19 | --transp-grey-background: #b4b4b422;
20 |
21 | --opaque-grey: hsla(0,0%,100%,.3);
22 | --light-grey-font: #b4b4b4;
23 | --dark-grey-font: #707070;
24 | --darkest-grey-font: #333333;
25 |
26 | --performance-green: #00FF66;
27 | --performance-red: #ff2222;
28 |
29 | /* margins */
30 | --details-page-margin-right: 50px;
31 | --overview-page-margin-right: 100px;
32 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getAPYs/earningAPYs/getUniswapEarningAPY.js:
--------------------------------------------------------------------------------
1 | import { getUniswapPoolVolume } from '../../protocolQueries';
2 |
3 | async function getUniswapEarningAPY(field, userTokens, userTokenPrices, earningAddress) {
4 | const { totalSupply } = field;
5 | const receiptToken = userTokens.find(token => token.tokenId === field.receiptToken).name;
6 | const totalValue = totalSupply * userTokenPrices[receiptToken].usd;
7 | const pairAddress = earningAddress.address;
8 | const first = 5;
9 |
10 | const dailyVolumeArr = await getUniswapPoolVolume(pairAddress, first);
11 | const trailingDailyVolume = dailyVolumeArr.data.pairDayDatas.reduce((acc, curr) => {
12 | return acc += curr.dailyVolumeUSD/first;
13 | }, 0)
14 |
15 | const APY = trailingDailyVolume * 0.003 * 365 / totalValue;
16 | return APY;
17 | }
18 |
19 | export default getUniswapEarningAPY;
--------------------------------------------------------------------------------
/client/src/components/OverViewCard/OverviewCard.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './OverviewCard.css';
3 |
4 | export default function OverviewCard ({title, amount, numType}) {
5 | let roiSign = '';
6 | let perfClass = '';
7 | if (numType === 'percent') {
8 | perfClass = '-percent';
9 |
10 | if (Number(amount)) {
11 | if (amount > 0) {
12 | roiSign = '+';
13 | perfClass = '-green';
14 | } else if (amount < 0) {
15 | roiSign = '-';
16 | perfClass = '-red';
17 | }
18 | amount = roiSign + Math.abs(amount);
19 | } else {
20 | amount = '--'
21 | }
22 | }
23 |
24 | return (
25 |
26 |
27 |
{title}
28 | {amount}
29 |
30 |
31 | )
32 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/protocolQueries/curveQueries/getCurveGaugeConstants.js:
--------------------------------------------------------------------------------
1 | import { ethers } from 'ethers';
2 |
3 | //cache
4 | let totalAnnualReward;
5 |
6 | async function getTotalAnnualReward (contract, decimals) {
7 | if (totalAnnualReward) return totalAnnualReward;
8 |
9 | const secondsPerYear = 3.154e7;
10 | const rewardRateBigInt = await contract.inflation_rate();
11 | const rewardRate = Number(ethers.utils.formatUnits(rewardRateBigInt, decimals));
12 | totalAnnualReward = rewardRate * secondsPerYear;
13 | return totalAnnualReward;
14 | }
15 |
16 |
17 | async function getFieldRewardPercent(contract, address, decimals) {
18 | const gaugeRewardWeight = await contract["gauge_relative_weight(address)"](address);
19 | return Number(ethers.utils.formatUnits(gaugeRewardWeight, decimals));
20 | }
21 |
22 | export {
23 | getTotalAnnualReward,
24 | getFieldRewardPercent
25 | }
--------------------------------------------------------------------------------
/client/src/helpers/ethHelpers/findFieldAddressType.js:
--------------------------------------------------------------------------------
1 | import fieldInterfaceTypes from '../../data/fieldData/fieldTypes';
2 |
3 | function findFieldAddressType (field, dataType) {
4 |
5 | const relevantAddress = field.contractAddresses.find(address => address.addressTypes.includes(dataType));
6 | if (!relevantAddress) throw new Error('No relevant address was found - findFieldAddressType()');
7 |
8 | const { address } = relevantAddress;
9 | const { ciId, abi } = relevantAddress.contractInterface;
10 |
11 | let addressType;
12 |
13 | for (let type in fieldInterfaceTypes) {
14 | if (fieldInterfaceTypes[type].filter(el => el.ciId === ciId).length) {
15 | addressType = type;
16 | break
17 | }
18 | }
19 | if (!addressType) {
20 | addressType = 'default';
21 | }
22 |
23 | return {address, abi, addressType};
24 | }
25 |
26 | export default findFieldAddressType;
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getAPYs/earningAPYs/getEarningAPYs.js:
--------------------------------------------------------------------------------
1 | import getCurveEarningAPY from './getCurveEarningAPY';
2 | import getUniswapEarningAPY from './getUniswapEarningAPY'
3 |
4 | async function getEarningAPYs (field, userTokens, userTokenPrices) {
5 |
6 | //get pair address
7 | const earningAddress = field.contractAddresses.find(address => address.addressTypes.includes('earning'));
8 | let APY;
9 |
10 | switch (earningAddress.contractInterface.name) {
11 | case "uniswap V2 earn":
12 | APY = await getUniswapEarningAPY(field, userTokens, userTokenPrices, earningAddress);
13 | break;
14 |
15 | case "curve swap 4 (sUSD)":
16 | case "curve swap 3 (sBTC)":
17 | case "curve swap 2 (hBTC)":
18 | APY = await getCurveEarningAPY(field);
19 | break;
20 |
21 | default:
22 |
23 | }
24 |
25 | return APY;
26 | }
27 |
28 | export default getEarningAPYs;
29 |
--------------------------------------------------------------------------------
/server/controllers/fields.js:
--------------------------------------------------------------------------------
1 | const Fields = require ('../models/fields');
2 | const path = require('path');
3 |
4 | async function getFields (req, res) {
5 | try {
6 | const fields = await Fields.getFields();
7 | res.status = 200;
8 | res.send(fields);
9 | } catch (err) {
10 | console.error(`Error at ${path.basename(__dirname)}/${path.basename(__filename)} ${err}`);
11 | res.sendStatus(500);
12 | }
13 | }
14 |
15 | async function getFieldWithReceiptToken (req, res) {
16 | try {
17 | receiptToken = req.params.receiptToken;
18 | const field = await Fields.getFieldWithReceiptToken(receiptToken);
19 | res.status = 200;
20 | res.send(field);
21 | } catch (err) {
22 | console.error(`Error at ${path.basename(__dirname)}/${path.basename(__filename)} ${err}`);
23 | res.sendStatus(500);
24 | }
25 | }
26 |
27 | module.exports = {
28 | getFields,
29 | getFieldWithReceiptToken
30 | }
--------------------------------------------------------------------------------
/client/src/apis/index.js:
--------------------------------------------------------------------------------
1 | // import { getTokens, getUserFieldTokens } from './simpleFi/tokens';
2 | // import { getFields } from './simpleFi/fields';
3 | import {
4 | getTokens,
5 | getUserFieldTokens,
6 | getFields,
7 | getUserTransactions
8 | } from './simpleFi'
9 | import getTokenPrices from './coinGecko/getTokenPrices';
10 | import {
11 | getUserBalance,
12 | getAllUserBalances,
13 | getUnclaimedRewards,
14 | createBalanceContracts,
15 | rewinder,
16 | getAPYs,
17 | getROIs,
18 | uniswapQueries
19 | } from './ethereum/index';
20 |
21 | //eslint-disable-next-line import/no-anonymous-default-export
22 | export default {
23 | getTokens,
24 | getUserFieldTokens,
25 | getFields,
26 | getUserTransactions,
27 | getTokenPrices,
28 | createBalanceContracts,
29 | getUserBalance,
30 | getAllUserBalances,
31 | getUnclaimedRewards,
32 | rewinder,
33 | getAPYs,
34 | getROIs,
35 | uniswapQueries
36 | }
--------------------------------------------------------------------------------
/client/src/helpers/detailsChartHelpers/chartCallbacks.js:
--------------------------------------------------------------------------------
1 | const chartCallbacks =
2 | {
3 | title: {
4 | farming: function(tooltipItem, data) {
5 | return data.datasets[0].labels[tooltipItem[0].index]
6 | },
7 | earningAndFarming: function(tooltipItem, data) {
8 | return data.datasets[0].other[tooltipItem[0].index].title;
9 | }
10 | },
11 |
12 | beforeBody: {
13 | earningAndFarming: function(tooltipItem, data) {
14 | return data.datasets[0].other[tooltipItem[0].index].beforeBody;
15 | }
16 | },
17 |
18 | label: {
19 | farming: function(tooltipItem, data) {
20 | return ` $${data.datasets[0].data[tooltipItem.index]} (${data.datasets[0].other[tooltipItem.index]})` ;
21 | },
22 | earningAndFarming: function(tooltipItem, data) {
23 | return ` $${(data.datasets[0].data[tooltipItem.index]).toLocaleString()}` ;
24 | }
25 | },
26 | }
27 |
28 | export default chartCallbacks;
--------------------------------------------------------------------------------
/client/src/apis/ethereum/index.js:
--------------------------------------------------------------------------------
1 | import {getUserBalance, getAllUserBalances} from './getUserBalances';
2 | import getUnclaimedRewards from './getUnclaimedRewards';
3 | import createBalanceContracts from './balanceContractCreator';
4 | import getTotalFieldSupply from './getTotalFieldSupply';
5 | import getFieldSeedReserves from './getFieldSeedReserves';
6 | import rewinder from './rewinder';
7 | import getAPYs from './getAPYs/getAPYs';
8 | import getROIs from './getROIs/getROIs';
9 | import {
10 | uniswapQueries,
11 | getOneCurvePoolRawData,
12 | getAllCurvePoolRawAPY,
13 | curveEPs
14 | } from './protocolQueries';
15 |
16 | export {
17 | createBalanceContracts,
18 | getUserBalance,
19 | getAllUserBalances,
20 | getUnclaimedRewards,
21 | getTotalFieldSupply,
22 | getFieldSeedReserves,
23 | rewinder,
24 | getAPYs,
25 | getROIs,
26 | uniswapQueries,
27 | getOneCurvePoolRawData,
28 | getAllCurvePoolRawAPY,
29 | curveEPs
30 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/protocolQueries/uniswapQueries/getUniswapGraphData.js:
--------------------------------------------------------------------------------
1 | import apollo from '../../../../apollo';
2 | import uniswapQueries from './uniswapGraphQueryStrings';
3 |
4 | async function getUniswapPoolVolume(pairAddress, first) {
5 | return await apollo.uniswapClient.query(
6 | {
7 | query: uniswapQueries.getUniswapPoolVolume,
8 | variables: { pairAddress, first }
9 | })
10 | }
11 |
12 | const uniswapBalanceCache = {};
13 | async function getUniswapBalanceHistory(userAccount) {
14 | if (uniswapBalanceCache[userAccount]) {
15 | return uniswapBalanceCache[userAccount];
16 | } else {
17 | uniswapBalanceCache[userAccount] = await apollo.uniswapClient.query(
18 | {
19 | query: uniswapQueries.getUniswapBalanceHistory,
20 | variables: { user: userAccount }
21 | })
22 | return uniswapBalanceCache[userAccount];
23 | }
24 | }
25 |
26 | export {
27 | getUniswapPoolVolume,
28 | getUniswapBalanceHistory
29 | }
30 |
--------------------------------------------------------------------------------
/client/src/components/SummaryTable/SummaryTable.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 |
3 | .summary-table {
4 | width: 100%;
5 | border-collapse: separate;
6 | border-spacing: 0px 15px;
7 | border-radius: 10px;
8 | }
9 |
10 | .summary-table-header .cell-header {
11 | color: var(--offwhite);
12 | }
13 |
14 | .summary-table-row {
15 | overflow: hidden;
16 | margin-bottom: 5px;
17 | border-radius: 10px;
18 | box-shadow: 0 1px 6px 0 rgba(0,0,0,.1);
19 | cursor: pointer;
20 | transition: 0.2s;
21 | position: relative;
22 | line-height: 60px;
23 | }
24 |
25 | .summary-table-row:hover {
26 | box-shadow: 0 0 5px 5px var(--periwinkle);
27 | }
28 |
29 | .summary-table-row td {
30 | background-color: white;
31 | }
32 |
33 | .summary-table-row td:first-child {
34 | border-top-left-radius: 10px;
35 | border-bottom-left-radius: 10px;
36 | }
37 |
38 | .summary-table-row td:last-child {
39 | border-top-right-radius: 10px;
40 | border-bottom-right-radius: 10px;
41 | }
42 |
43 |
--------------------------------------------------------------------------------
/client/src/apis/coinGecko/getHistoricalPrice.js:
--------------------------------------------------------------------------------
1 | import fetchRequest from '../fetchRequest';
2 | import {baseUrl, priceEP, history} from './geckoEndPoints';
3 |
4 | /**
5 | * @param {uuid} tokenId - db Id of currently analysed token
6 | * @param {String} date - date formatted for a historical price query to coingecko
7 | * @dev same end point can be used for other historical market data provided by coinGecko
8 | * @return {Object} - token price on day specified
9 | */
10 |
11 | const priceCache = [];
12 |
13 | function getHistoricalPrice (tokenId, date) {
14 | const preExisting = priceCache.find(cached => cached.tokenId === tokenId && cached.date === date);
15 | if (preExisting) {
16 | return preExisting.data;
17 | }
18 | else return fetchRequest(baseUrl + priceEP + tokenId + history + date)
19 | .then(token => {
20 | const data = token.market_data.current_price.usd;
21 | priceCache.push({tokenId, date, data})
22 | return data;
23 | })
24 | }
25 |
26 | export default getHistoricalPrice;
--------------------------------------------------------------------------------
/client/src/apis/ethereum/protocolQueries/uniswapQueries/uniswapGraphQueryStrings.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client';
2 |
3 | const getUniswapPoolVolume =
4 | gql`
5 | query getUniswapPoolVolume ($pairAddress: String! $first: Int!) {
6 | pairDayDatas (
7 | where: {pairAddress: $pairAddress}
8 | orderBy: date
9 | orderDirection: desc
10 | first: $first
11 | ) {
12 | dailyVolumeUSD
13 | }
14 | }
15 | `
16 |
17 | const getUniswapBalanceHistory =
18 | gql`
19 | query getUserBalanceHistory ($user: String!) {
20 | liquidityPositionSnapshots (
21 | where: {user: $user}
22 | orderBy: timestamp
23 | orderDirection: asc
24 | ) {
25 | timestamp
26 | pair {
27 | id
28 | }
29 | block
30 | liquidityTokenBalance
31 | liquidityTokenTotalSupply
32 | reserveUSD
33 | }
34 | }
35 | `
36 |
37 | //eslint-disable-next-line import/no-anonymous-default-export
38 | export default {
39 | getUniswapPoolVolume,
40 | getUniswapBalanceHistory
41 | }
--------------------------------------------------------------------------------
/client/src/authentication/web3.js:
--------------------------------------------------------------------------------
1 |
2 | async function metamaskConnect () {
3 | try {
4 | const account = await window.ethereum.request({ method: 'eth_requestAccounts' });
5 | return account
6 | } catch(error) {
7 | console.error(error);
8 | return {error};
9 | }
10 | }
11 |
12 | async function connectWallet (setUserAccount, history, userAccount) {
13 | if (window.ethereum) {
14 | const newAccount = await metamaskConnect();
15 | if (newAccount.error) {
16 | if (newAccount.error.code === 4001) {
17 | alert('Please connect to Metamask');
18 | } else {
19 | alert('Oops, something went wrong - please refresh the page');
20 | }
21 | } else if (newAccount[0] && newAccount[0] !== userAccount[0]) {
22 | setUserAccount(newAccount);
23 | history.push('/dashboard');
24 | } else {
25 | history.push('/dashboard');
26 | }
27 | } else {
28 | alert('Please install Metamask to use SimpleFi (https://metamask.io/)')
29 | }
30 | }
31 |
32 | export {
33 | connectWallet,
34 | }
--------------------------------------------------------------------------------
/client/src/components/DropdownButton/DropdownButton.css:
--------------------------------------------------------------------------------
1 | .dropdown-wrapper {
2 | display: flex;
3 | justify-content: center;
4 | align-items: center;
5 | position: relative;
6 | }
7 |
8 | .dropdown, .dropdown-active {
9 | width: 20px;
10 | height: 20px;
11 | display: inline-block;
12 | position: relative;
13 | text-align: left;
14 | }
15 |
16 | .dropdown {
17 | transform: rotate(45deg);
18 | }
19 |
20 | .dropdown:before, .dropdown:after, .dropdown-active:before, .dropdown-active:after {
21 | position: absolute;
22 | content: '';
23 | display: inline-block;
24 | width: 12px;
25 | height: 3px;
26 | background-color: #fff;
27 | }
28 |
29 | .dropdown:after {
30 | position: absolute;
31 | transform: rotate(90deg);
32 | top: 5px;
33 | left: 4.5px;
34 | }
35 |
36 | .dropdown-active {
37 | transform: rotate(45deg) translate(-5px,-5px);
38 | }
39 |
40 | .dropdown-active:before {
41 | transform: translate(10px,0);
42 | }
43 |
44 | .dropdown-active:after {
45 | transform: rotate(90deg) translate(4.5px,-5.5px);
46 | }
47 |
48 | .dropdown-wrapper:hover {
49 | cursor: pointer;
50 | }
--------------------------------------------------------------------------------
/client/src/helpers/ethHelpers/ethROIHelpers/calcEarningROI.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * @param {Number} currInvestmentValue - current value of investment in analysed field
4 | * @param {Array} txHistory - pre-sorted list of user interactions with analysed field
5 | * @return {Number} - user ROI to date with regards to the analysed field, defined as:
6 | * (current investment value + sum of realised exits [txOut]) / sum of historical investments [txIn]
7 | */
8 | function calcEarningROI (currInvestmentValue, txHistory) {
9 | let valueInvested = 0;
10 | let valueRealised = 0;
11 |
12 | txHistory.forEach(tx => {
13 | const { txIn, txOut, pricePerToken } = tx;
14 | if (txIn || txOut) {
15 | txIn ? valueInvested += txIn * pricePerToken : valueRealised += txOut * pricePerToken
16 | }
17 | })
18 | const absReturnValue = currInvestmentValue + valueRealised - valueInvested;
19 | const allTimeROI = ((currInvestmentValue + valueRealised) / valueInvested) - 1;
20 |
21 | return {allTimeROI, absReturnValue, histInvestmentValue: valueInvested}
22 | }
23 |
24 | export default calcEarningROI;
--------------------------------------------------------------------------------
/client/src/helpers/appHelpers/addFieldSuppliesAndReserves.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * @param {Array} suppliesAndBalances - contains each fields totoal supply and underlying token reserves. Set during rewind
4 | * @param {Array} userFields - all fields a user has invested in
5 | * @return {Array} - a new userField array containing a total supply property, and a new fieldReserve property on each seed token
6 | */
7 |
8 | function addFieldSuppliesAndReserves (suppliesAndBalances, userFields) {
9 |
10 | const updatedUserFields = [...userFields];
11 |
12 | suppliesAndBalances.forEach(suppliedField => {
13 | const targetField = userFields.find(userField => userField.name === suppliedField.fieldName);
14 | targetField.totalSupply = suppliedField.totalFieldSupply;
15 |
16 | suppliedField.seedReserves.forEach(reservedToken => {
17 | const targetSeed = targetField.seedTokens.find(seedToken => seedToken.name === reservedToken.tokenName);
18 | targetSeed.fieldReserve = reservedToken.fieldReserve
19 | })
20 | })
21 | return updatedUserFields;
22 | }
23 |
24 | export default addFieldSuppliesAndReserves;
--------------------------------------------------------------------------------
/client/src/helpers/ethHelpers/ethROIHelpers/createWhitelist.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * @param {Array} trackedFields - all tracked fields
4 | * @param {Object} field - currently analysed earning field
5 | * @dev staking/unstaking field receipt tokens doesn't change the user's underlying balance so the corresponding addresses are "whitelisted"
6 | * this helper assumes deposit and withdrawal addresses of the staking contract are the same
7 | * @return {Array} - a list of staking/unstaking addresses for use in the liquidity history extraction func
8 | *
9 | */
10 | function createWhitelist(trackedFields, field) {
11 | const whitelist = [];
12 | trackedFields.forEach(trackedField => {
13 | trackedField.seedTokens.forEach(seedToken => {
14 | if (seedToken.tokenId === field.receiptToken) {
15 | const depositAddresses = trackedField.contractAddresses.filter(address => address.addressTypes.includes('deposit'));
16 | depositAddresses.forEach(depositAddress => whitelist.push(depositAddress.address.toLowerCase()))
17 | }
18 | })
19 | })
20 | return whitelist;
21 | }
22 |
23 | export default createWhitelist
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getROIs/earningROIs/getUniswapLiquidityHistory.js:
--------------------------------------------------------------------------------
1 | import { getUniswapBalanceHistory } from '../../protocolQueries';
2 | import helpers from '../../../../helpers';
3 |
4 | async function getUniswapLiquidityHistory (field, userReceiptTokenTxs, userAccount, whitelist) {
5 |
6 | const rawData = await getUniswapBalanceHistory(userAccount);
7 | const fieldBalanceHistory = rawData.data.liquidityPositionSnapshots.filter(snapshot => snapshot.pair.id === field.contractAddresses[0].address.toLowerCase());
8 |
9 | const liquidityHistory = userReceiptTokenTxs.map(tx => {
10 | const txDate = new Date(Number(tx.timeStamp) * 1000);
11 | const targetSnapshot = fieldBalanceHistory.find(snapshot => tx.blockNumber === snapshot.block.toString());
12 | const pricePerToken = Number(targetSnapshot.reserveUSD) / Number(targetSnapshot.liquidityTokenTotalSupply);
13 | const {txIn, txOut, staked, unstaked} = helpers.sortLiquidityTxs(tx, userAccount, whitelist);
14 | return {tx, txDate, pricePerToken, txIn, txOut, staked, unstaked}
15 | })
16 |
17 | return liquidityHistory;
18 | }
19 |
20 | export default getUniswapLiquidityHistory;
21 |
--------------------------------------------------------------------------------
/client/src/components/OverViewCard/OverviewCard.css:
--------------------------------------------------------------------------------
1 | .overview-card {
2 | display: flex;
3 | border-radius: 10px;
4 | box-shadow: 0 0px 2px 0px var(--periwinkle);
5 | min-width: 220px;
6 | min-height: 120px;
7 | }
8 |
9 | .card-headline, .card-headline-green, .card-headline-red, .card-headline-percent {
10 | display: flex;
11 | margin: auto;
12 | flex-direction: column;
13 | align-items: center;
14 | }
15 |
16 | .card-headline h1, .card-headline-green h1, .card-headline-red h1, .card-headline-percent h1 {
17 | font-size: 1.5em;
18 | font-weight: 300;
19 | color: var(--offwhite);
20 | margin-bottom: 15px;
21 | }
22 |
23 | .card-headline h2, .card-headline-percent h2 {
24 | font-size: 1.5em;
25 | color: var(--almost-white);
26 | }
27 |
28 | .card-headline-green h2 {
29 | font-size: 1.5em;
30 | color: var(--performance-green);
31 | }
32 |
33 | .card-headline-red h2 {
34 | font-size: 1.5em;
35 | color: var(--performance-red);
36 | }
37 |
38 | .card-headline h2:before {
39 | content: '$';
40 | }
41 |
42 | .card-headline-green h2:after, .card-headline-red h2:after, .card-headline-percent h2:after {
43 | content: '%';
44 | }
45 |
--------------------------------------------------------------------------------
/server/apis/userTransactions.js:
--------------------------------------------------------------------------------
1 | const path = require ('path');
2 | const fetchRequest = require('./fetchRequest');
3 |
4 | const etherscanTokenTxEP = "https://api.etherscan.io/api?module=account&action=tokentx&address=";
5 | const etherscanNormalTxEP = "https://api.etherscan.io/api?module=account&action=txlist&address=";
6 | const etherscanAPI = '&apikey=' + process.env.ETHERSCAN_API_KEY;
7 |
8 | async function getUserTokenTransactions(address) {
9 | try {
10 | const userTransactions = await fetchRequest(etherscanTokenTxEP + address + etherscanAPI);
11 | return userTransactions;
12 | } catch(err) {
13 | console.error(`Error at ${path.basename(__dirname)}/${path.basename(__filename)} ${err}`);
14 | }
15 | }
16 |
17 | async function getUserNormalTransactions(address) {
18 | try {
19 | const userTransactions = await fetchRequest(etherscanNormalTxEP + address + etherscanAPI);
20 | return userTransactions;
21 | } catch(err) {
22 | console.error(`Error at ${path.basename(__dirname)}/${path.basename(__filename)} ${err}`);
23 | }
24 | }
25 |
26 | module.exports = {
27 | getUserTokenTransactions,
28 | getUserNormalTransactions
29 | }
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@apollo/client": "^3.2.6",
7 | "@testing-library/jest-dom": "^4.2.4",
8 | "@testing-library/react": "^9.5.0",
9 | "@testing-library/user-event": "^7.2.1",
10 | "chart.js": "^2.9.4",
11 | "ethers": "^5.0.12",
12 | "graphql": "^15.4.0",
13 | "react": "^16.13.1",
14 | "react-blockies": "^1.4.1",
15 | "react-dom": "^16.13.1",
16 | "react-router-dom": "^5.2.0",
17 | "react-scripts": "^4.0.0",
18 | "typescript": "^4.1.3"
19 | },
20 | "scripts": {
21 | "start": "react-scripts start",
22 | "build": "react-scripts build",
23 | "test": "react-scripts test",
24 | "eject": "react-scripts eject"
25 | },
26 | "eslintConfig": {
27 | "extends": "react-app"
28 | },
29 | "browserslist": {
30 | "production": [
31 | ">0.2%",
32 | "not dead",
33 | "not op_mini all"
34 | ],
35 | "development": [
36 | "last 1 chrome version",
37 | "last 1 firefox version",
38 | "last 1 safari version"
39 | ]
40 | },
41 | "devDependencies": {}
42 | }
43 |
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getAPYs/farmingAPYs/getFarmingAPYs.js:
--------------------------------------------------------------------------------
1 | import getSnxFarmingAPY from './getSnxFarmingAPY';
2 | import getCurveFarmingAPY from './getCurveFarmingAPY';
3 |
4 | //formula for APY: (yearlyReward * cropPrice) / (totalSupply * seedPrice)
5 | //TODO: add documentation
6 | //TODO: refactor to add secondary Farming APY - already provided in sub functions (e.g. getCurveFarmingAPY)
7 | async function getFarmingAPYs (field, userTokenPrices) {
8 | const rewardRateAddress = field.contractAddresses.find(address => address.addressTypes.includes('rewardRate'));
9 | let APY;
10 |
11 | switch (rewardRateAddress.contractInterface.name) {
12 |
13 | case "synthetix susd farm":
14 | case "mstable farm":
15 | //CHECK: rename function if used for more than one field?
16 | APY = await getSnxFarmingAPY(rewardRateAddress, field, userTokenPrices);
17 | break;
18 |
19 | case 'curve reward gauge':
20 | APY = await getCurveFarmingAPY(rewardRateAddress, field, userTokenPrices);
21 | break;
22 |
23 | default:
24 | APY = 'undefined';
25 | }
26 | return APY;
27 | }
28 |
29 | export default getFarmingAPYs;
--------------------------------------------------------------------------------
/client/src/helpers/appHelpers/addFieldInvestmentValues.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * @param {Array} userFields - all fields the user has invested in
4 | * @param {Object} tokenPrices - prices of all user tokens
5 | * @return {Array} user fields updated with the value of the user's investment in them
6 | */
7 |
8 | function addFieldInvestmentValues(userFields, tokenPrices) {
9 | const updatedFields = [...userFields];
10 |
11 | updatedFields.forEach(field => {
12 | let totalFieldValue = 0;
13 | field.seedTokens.forEach(token => {
14 | totalFieldValue += token.fieldReserve * tokenPrices[token.name].usd;
15 | })
16 |
17 | if (field.userBalance) {
18 | field.unstakedUserInvestmentValue = (field.userBalance / field.totalSupply) * totalFieldValue;
19 | } else {
20 | field.unstakedUserInvestmentValue = 0;
21 | }
22 | if (field.stakedBalance) {
23 | field.stakedBalance.forEach(
24 | stakedBalance => stakedBalance.userInvestmentValue = (stakedBalance.balance / field.totalSupply) * totalFieldValue
25 | )
26 | }
27 | })
28 |
29 | return updatedFields;
30 | }
31 |
32 | export default addFieldInvestmentValues;
--------------------------------------------------------------------------------
/client/src/components/MiniToggle/MiniToggle.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 |
3 | .mini-toggle-container {
4 | display: flex;
5 | }
6 |
7 | .mini-toggle-container p {
8 | font-size: 0.7em;
9 | font-weight: normal;
10 | color: var(--offwhite);
11 | }
12 |
13 | .mini-toggle {
14 | -webkit-appearance: none;
15 | -moz-appearance: none;
16 | appearance: none;
17 | -webkit-tap-highlight-color: transparent;
18 | cursor: pointer;
19 | height: 14px;
20 | width: 24px;
21 | border-radius: 8px;
22 | position: relative;
23 | margin: 0 5px 0 5px;
24 | border: 1px solid #474755;
25 | background: linear-gradient(180deg, var(--dark-grey-font) 0%, var(--darkest-grey-font) 100%);
26 | }
27 | .mini-toggle:focus {
28 | outline: 0;
29 | }
30 | .mini-toggle:after {
31 | content: "";
32 | position: absolute;
33 | top: 1px;
34 | left: 1px;
35 | width: 10px;
36 | height: 10px;
37 | border-radius: 50%;
38 | background: white;
39 | box-shadow: 0 1px 2px rgba(44, 44, 44, 0.2);
40 | transition: all cubic-bezier(0.5, 0.1, 0.75, 1.35);
41 | }
42 | .mini-toggle:checked {
43 | border-color: var(--periwinkle);
44 | }
45 | .mini-toggle:checked:after {
46 | transform: translatex(10px);
47 | }
--------------------------------------------------------------------------------
/client/src/helpers/summaryBoxHelper.js:
--------------------------------------------------------------------------------
1 | export default function formatHeadlines(tableName, headlines) {
2 | const formatter = new Intl.NumberFormat("en-US", {
3 | style: 'percent',
4 | maximumFractionDigits: 1
5 | });
6 |
7 | const formattedHeadlines = [];
8 | let perfClasses = [];
9 | if (tableName === 'holding') {
10 | const {totalValue, totalInvested, totalUnclaimed} = headlines;
11 | formattedHeadlines.push(`${formatter.format(totalInvested / totalValue)} invested`);
12 | formattedHeadlines.push(`${formatter.format(totalUnclaimed / totalValue)} unclaimed`);
13 | perfClasses = [false, false];
14 | } else {
15 | let roiSign;
16 | if (headlines.ROI > 0) {
17 | roiSign = '+';
18 | perfClasses.push('green');
19 | } else if (headlines.ROI < 0) {
20 | roiSign = '-';
21 | perfClasses.push('red');
22 | } else {
23 | roiSign = '';
24 | perfClasses.push(null)
25 | }
26 |
27 | formattedHeadlines.push(`${roiSign}${formatter.format(headlines.ROI)}`);
28 | formattedHeadlines.push(`$${Number(headlines.investment?.toFixed()).toLocaleString()} invested`);
29 | perfClasses.push(null);
30 | }
31 | return {formattedHeadlines, perfClasses}
32 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/protocolQueries/curveQueries/getRawCurvePoolData.js:
--------------------------------------------------------------------------------
1 | import curveEPs from './curveRawStatsEPs';
2 | import fetchRequest from '../../../fetchRequest';
3 |
4 |
5 | const { curveMainEP, apyEP, indivPoolEPs, indivPoolConcat } = curveEPs
6 | let apyCache;
7 | const poolCache ={};
8 |
9 | /**
10 | * @dev - this ep can also be used to get volume for all pools
11 | * @return {array} current nominal daily, monthly, weekly and total APY for all Curve pools
12 | */
13 | async function getAllCurvePoolRawAPY() {
14 | if (!apyCache) {
15 | apyCache = await fetchRequest(curveMainEP + apyEP);
16 | return apyCache
17 | }
18 | return apyCache;
19 | }
20 |
21 | /**
22 | *
23 | * @param {String} name - name of the curve earning (liquidity) field
24 | * @return {Array} - full daily pool data history, including reserves, timestamp, balances, etc.
25 | */
26 | async function getOneCurvePoolRawData(name) {
27 | if (poolCache[name]) {
28 | return poolCache[name]
29 | } else {
30 | const path = indivPoolEPs[name] + indivPoolConcat;
31 | poolCache[name] = await fetchRequest(curveMainEP + path);
32 | return poolCache[name];
33 | }
34 | }
35 |
36 | export {
37 | getOneCurvePoolRawData,
38 | getAllCurvePoolRawAPY
39 | }
--------------------------------------------------------------------------------
/client/src/helpers/appHelpers/loadingModalHelper.js:
--------------------------------------------------------------------------------
1 | export default function amendModal(message, loadingMessage) {
2 | let headline;
3 | let actions;
4 | switch (message) {
5 |
6 | case 'balances':
7 | headline = 'Loading balances';
8 | actions = [
9 | 'Fetching token and field balances',
10 | 'Fetching historic token transactions',
11 | 'Fetching unclaimed rewards',
12 | ]
13 | break;
14 |
15 | case 'rewinding':
16 | headline = 'Rewinding invested balances';
17 | actions = [
18 | 'Rewinding underlying farming investments',
19 | 'Rewinding underlying tokens'
20 |
21 | ];
22 | break;
23 |
24 | case 'ROIs':
25 | headline = 'Calculating APYs and ROIs';
26 | actions = [
27 | 'Fetching token and field prices',
28 | 'Calculating APYs',
29 | 'Calculating ROIs'
30 | ]
31 | break;
32 |
33 | default:
34 | headline = loadingMessage.headline;
35 | actions = loadingMessage.actions.map(
36 | action => {
37 | if (action === message) {
38 | action = action + ' ✔️';
39 | }
40 | return action
41 | }
42 | );
43 | }
44 | return {headline, actions}
45 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getROIs/farmingROIs/getUniswapFarmingPriceHistory.js:
--------------------------------------------------------------------------------
1 | import { getUniswapBalanceHistory } from "../../protocolQueries";
2 |
3 | /**
4 | * @param {Object} blockNumber - block number at which the historical token price is sought
5 | * @param {String} userAccount
6 | * @dev - this assumes that the data from the Graph on the user's Uniswap balances will always match Etherscan's user ERC20 tx data
7 | * (it should as the Graph records balance changes on block within which user staked/unstaked the receipt token)
8 | * note: target token is not specified - assumes there will always only be one Uniswap tx per block
9 | * @return {Object} - price of the uniswap receipt token and the date of the transaction at which it is sought
10 | */
11 | async function getOneUniswapHistReceiptPrice (blockNumber, userAccount) {
12 | const rawData = await getUniswapBalanceHistory(userAccount);
13 | const targetBlock = rawData.data.liquidityPositionSnapshots.find(data => data.block === Number(blockNumber));
14 | const pricePerToken = Number(targetBlock.reserveUSD) / Number(targetBlock.liquidityTokenTotalSupply);
15 | const txDate = new Date(Number(targetBlock.timestamp) * 1000);
16 |
17 | return {pricePerToken, txDate};
18 | }
19 |
20 | export default getOneUniswapHistReceiptPrice;
--------------------------------------------------------------------------------
/client/src/components/MaxiToggle/MaxiToggle.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 |
3 | .maxi-toggle-container {
4 | display: flex;
5 | }
6 |
7 | .maxi-toggle-container p {
8 | font-size: 0.7em;
9 | font-weight: normal;
10 | color: var(--offwhite);
11 | }
12 |
13 | .maxi-toggle {
14 | -webkit-appearance: none;
15 | -moz-appearance: none;
16 | appearance: none;
17 | -webkit-tap-highlight-color: transparent;
18 | cursor: pointer;
19 | height: 20px;
20 | width: 34px;
21 | border-radius: 14px;
22 | position: relative;
23 | border: 1px solid var(--darkest-grey-font);
24 | background: linear-gradient(180deg, var(--light-grey-font) 0%, var(--dark-grey-font) 100%);
25 | transition: all .2s ease;
26 | }
27 |
28 | .maxi-toggle:focus {
29 | outline: 0;
30 | }
31 | .maxi-toggle:after {
32 | content: "";
33 | position: absolute;
34 | top: 2px;
35 | left: 2px;
36 | width: 14px;
37 | height: 14px;
38 | border-radius: 50%;
39 | background: white;
40 | box-shadow: 0 1px 2px rgba(44, 44, 44, 0.2);
41 | transition: all .2s cubic-bezier(0.5, 0.1, 0.75, 1.35);
42 | }
43 | .maxi-toggle:checked {
44 | border-color: var(--periwinkle);
45 | background: linear-gradient(180deg, var(--light-grey-font) 0%, var(--success-green) 100%);;
46 | }
47 | .maxi-toggle:checked:after {
48 | transform: translatex(14px);
49 | }
--------------------------------------------------------------------------------
/server/models/tokens.js:
--------------------------------------------------------------------------------
1 | const prisma = require('./db');
2 | const path = require ('path');
3 |
4 | async function getTokens() {
5 | try {
6 | const tokens = await prisma.token.findMany({
7 | include: {
8 | contractInterface: {
9 | select: {
10 | name: true,
11 | abi: true,
12 | isErc: true,
13 | decimals: true
14 | }
15 | },
16 | protocol: {
17 | select: {
18 | name: true
19 | }
20 | }
21 | }
22 | });
23 | return tokens;
24 | } catch(err) {
25 | console.error(`Error at ${path.basename(__dirname)}/${path.basename(__filename)} ${err}`);
26 | }
27 | finally {
28 | (async () => {await prisma.$disconnect()})();
29 | }
30 | }
31 |
32 | async function selectUserFieldTokens(queryStr) {
33 | try {
34 | const tokens = await prisma.token.findMany({
35 | where: {
36 | tokenId: {
37 | in: queryStr
38 | }
39 | }
40 | })
41 | return tokens;
42 | } catch (err) {
43 | console.error(`Error at ${path.basename(__dirname)}/${path.basename(__filename)} ${err}`);
44 | }
45 | finally {
46 | (async () => {await prisma.$disconnect()})();
47 | }
48 | }
49 |
50 | module.exports = {
51 | getTokens,
52 | selectUserFieldTokens
53 | }
--------------------------------------------------------------------------------
/client/src/helpers/detailsChartHelpers/detailsBarChartHelper.js:
--------------------------------------------------------------------------------
1 | import pieChartColours from '../../data/pieChartColours';
2 |
3 | export default function extractDetailsBarChartValues(data, type) {
4 | const extractedValues = {data:[], labels: [], fill: [], other:[]};
5 | let colourIndex = 0;
6 |
7 | if (type === 'earningAndFarming') {
8 | const {earningField, farmingFields} = data;
9 |
10 | if (earningField) {
11 | extractedValues.data.push(Number(earningField.earningROI.absReturnValue.toFixed(2)));
12 | extractedValues.labels.push('This field');
13 | extractedValues.other.push({title: 'Core earnings', beforeBody: earningField.name});
14 | extractedValues.fill.push(pieChartColours[colourIndex]);
15 | colourIndex++;
16 | }
17 |
18 | if (farmingFields.length) {
19 | farmingFields.forEach(farmingField => {
20 | extractedValues.data.push(Number(farmingField.farmingROI.absReturnValue.toFixed(2)));
21 | extractedValues.labels.push(`Farming: ${farmingField.name.length >= 8 ? farmingField.name.slice(0, 5) + '...' : farmingField.name}`);
22 | extractedValues.other.push({title: 'Farming rewards', beforeBody: farmingField.name});
23 | extractedValues.fill.push(pieChartColours[colourIndex]);
24 | colourIndex++;
25 | })
26 | }
27 | }
28 |
29 | return extractedValues;
30 | }
--------------------------------------------------------------------------------
/client/src/components/LoadingModal/LoadingModal.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect} from 'react';
2 | import './LoadingModal.css'
3 |
4 | export default function LoadingModal({splash, loadingMessage}) {
5 |
6 | const [display, setDisplay] = useState({display: 'none'});
7 |
8 | useEffect(() => {
9 | const {headline} = loadingMessage;
10 | if (splash && headline) {
11 | setDisplay({display: 'block'});
12 | } else {
13 | setDisplay({display: 'none'})
14 | }
15 | }, [splash, loadingMessage])
16 |
17 | return (
18 |
19 |
20 |
21 |
{loadingMessage.headline}
22 |
23 | {loadingMessage.actions.map((action) => {
24 | let tick;
25 | if (action.slice(-2) === '✔️') {
26 | tick =
✔
27 | action = action.slice(0, -2);
28 | }
29 | return (
{action}{tick}
)
30 | })
31 | }
32 |
33 |
34 |
37 |
38 |
39 | )
40 | }
--------------------------------------------------------------------------------
/client/src/containers/FarmingFieldDetails/FarmingFieldDetails.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 | /* See Earning field and Token details for additional/common CSS */
3 |
4 | .farming-details-overviews {
5 | display: flex;
6 | justify-content: center;
7 | margin-right: var(--details-page-margin-right);
8 | padding-left: 50px;
9 | height: 300px;
10 | }
11 |
12 | .farming-details-numbers {
13 | display: flex;
14 | flex: 1.5;
15 | flex-direction: column;
16 | align-items: center;
17 | justify-content: space-between;
18 | }
19 |
20 | .farming-overview {
21 | display: flex;
22 | margin: 15px 20px;
23 | padding: 5px 15px 1px 15px;
24 | flex-direction: column;
25 | align-items: center;
26 | justify-content: space-around;
27 | border-radius: 10px;
28 | box-shadow: 0 0px 2px 0px var(--periwinkle);
29 | border-width: 1px;;
30 | width: 180px;
31 | height: 140px;
32 | }
33 |
34 | .farming-overview h2 {
35 | text-align: center;
36 | }
37 |
38 | .farming-overview p {
39 | font-size: 1.2em;
40 | font-weight: bold;
41 | }
42 |
43 | .farming-source-container {
44 | display: flex;
45 | flex: 2;
46 | flex-direction: column;
47 | position: relative;
48 | align-items: flex-start;
49 | }
50 |
51 | .farming-source-container h2 {
52 | margin-top: 15px;
53 | margin-bottom: 15px;
54 | }
55 |
56 | .farming-source-chart {
57 | position: relative;
58 | height: 250px;
59 | width: 500px;
60 | }
--------------------------------------------------------------------------------
/client/src/helpers/ethHelpers/ethROIHelpers/sortLiquidityTxs.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * @param {Object} tx - currently analysed tx (filtered from Etherscan fetch of user ERC20 transfer history)
4 | * @param {String} userAccount user Ethereum address
5 | * @param {Array} whitelist - list of seed token staking addresses to or from which
6 | * transactions don't change the user's underlying holding
7 | * @dev illustration: when staking a user sends to the farming contract's deposit address, but keeps stake in earning field
8 | * idem: when user unstakes, they receive receipt tokens from the staking field, but should not count as a user having
9 | * bought the tokens later: this whitelist concept will be key to combining user Accounts for an aggregate view
10 | *
11 | */
12 | function sortLiquidityTxs (tx, userAccount, whitelist) {
13 | let txIn, txOut;
14 | let staked, unstaked;
15 | const txAmount = tx.value / Number(`1e${tx.tokenDecimal}`);
16 |
17 | if (tx.from === userAccount.toLowerCase()) {
18 | if (whitelist.includes(tx.to)) {
19 | staked = txAmount;
20 | } else {
21 | txOut = txAmount;
22 | }
23 | } else {
24 | if (whitelist.includes(tx.from)) {
25 | unstaked = txAmount;
26 | } else {
27 | txIn = txAmount;
28 | }
29 | }
30 | return {txIn, txOut, staked, unstaked}
31 | }
32 |
33 | export default sortLiquidityTxs;
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getUnclaimedRewards.js:
--------------------------------------------------------------------------------
1 | import { ethers } from 'ethers';
2 | import provider from './ethProvider';
3 |
4 | async function getUnclaimedRewards(userAccount, trackedFields) {
5 | const unclaimedCropBalances = [];
6 | const farmFields = trackedFields.filter(field => field.cropTokens.length)
7 |
8 | for (let field of farmFields) {
9 | const { cropTokens } = field;
10 | for (let cropToken of cropTokens) {
11 | const { tokenId } = cropToken;
12 | //@dev: assume that the same contract address is used to check the balance of multiple crop tokens (albeit with different methods saved in the cropToken DB table)
13 | try {
14 | const targetAddress = field.contractAddresses.find(contract => contract.addressTypes.includes('unclaimedReward'));
15 | const unclaimedRewardContract = new ethers.Contract(targetAddress.address, targetAddress.contractInterface.abi, provider);
16 | let unclaimedBalance = await unclaimedRewardContract[cropToken.unclaimedBalanceMethod](userAccount);
17 | unclaimedBalance = Number(ethers.utils.formatUnits(unclaimedBalance, targetAddress.contractInterface.decimals));
18 | if (unclaimedBalance) {
19 | unclaimedCropBalances.push({field, tokenId, unclaimedBalance});
20 | }
21 | } catch (err) {
22 | console.error('Unclaimed rewards error', err);
23 | }
24 | }
25 | }
26 | return unclaimedCropBalances;
27 | }
28 |
29 | export default getUnclaimedRewards;
--------------------------------------------------------------------------------
/client/src/components/Welcome/Welcome.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import simpleFiSplash from '../../assets/images/simpleFi-splash-blue-sun.svg';
3 | import simpleFiLogo from '../../assets/logos/simplefi-logotype.svg';
4 | import './Welcome.css';
5 | import { connectWallet } from '../../authentication/web3';
6 | import { useHistory } from 'react-router-dom';
7 |
8 | export default function Welcome ({setUserAccount, userAccount, setSplash}) {
9 |
10 | const history = useHistory();
11 |
12 | useEffect(() => {
13 | setSplash(false);
14 | return () => setSplash(true);
15 | // eslint-disable-next-line react-hooks/exhaustive-deps
16 | })
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
Making decentralized finance accessible to everyone
26 |
27 |
28 | connectWallet(setUserAccount, history, userAccount)}>{userAccount[0] ? 'View dashboard' : 'Connect wallet'}
29 |
30 |
31 |
32 |
33 |
34 |
35 | )
36 | }
--------------------------------------------------------------------------------
/client/src/components/Welcome/Welcome.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 |
3 | .welcome {
4 | display: flex;
5 | position: relative;
6 | }
7 |
8 | .welcome-splash {
9 | flex: 3;
10 | display: flex;
11 | flex-direction: column;
12 | }
13 |
14 | .splash-main {
15 | display: flex;
16 | flex-direction: column;
17 | }
18 |
19 | .splash-image {
20 | height: 100px;
21 | margin-left: 115px;
22 | max-height: 65px;
23 | margin-bottom: 50px;
24 | }
25 |
26 | .welcome-splash-image {
27 | height: 100%;
28 | }
29 |
30 | .welcome-splash h2 {
31 | all: unset;
32 | color: white;
33 | margin-left: 120px;
34 | margin-bottom: 50px;
35 | }
36 |
37 | .welcome-splash h1 {
38 | margin-top: 50px;
39 | font-size: 3.5em;
40 | font-weight: 700;
41 | font-family: 'Baloo 2', cursive;
42 | }
43 |
44 | .welcome-splash h2 {
45 | font-size: 1.8em;
46 | line-height: 2em;
47 | }
48 |
49 | .splash-connect {
50 | margin-left: 120px;
51 | }
52 |
53 | .welcome-media {
54 | flex: 3;
55 | max-height: 300px;
56 | position: relative;
57 | }
58 |
59 | .welcome-media-image {
60 | width: 85%;
61 | }
62 |
63 | Link {
64 | text-decoration: none;
65 | }
66 |
67 | .welcome-button {
68 | display: flex;
69 | justify-content: center;
70 | background-color: var(--sky-blue);
71 | color: white;
72 | font-size: 1.3em;
73 | font-weight: 300;
74 | margin: 45px 50px 50px 0px;;
75 | padding: 12px 20px;
76 | border-radius: 8px;
77 | box-shadow: 1px 1px 3px 0px rgba(0,0,0,0.5);
78 | cursor: pointer;
79 | }
--------------------------------------------------------------------------------
/client/src/components/SummaryBox/SummaryBox.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 | @import '../../CSS/animations.css';
3 |
4 | .box-container-headline {
5 | display: flex;
6 | justify-content: space-between;
7 | padding: 20px 0 20px 20px;
8 | }
9 |
10 | .box-container-headline h2 {
11 | flex: 1;
12 | margin-right: 20px;
13 | color: white;
14 | display: block;
15 | font-size: 1.5em;
16 | margin-left: 0;
17 | font-weight: 500;
18 | }
19 |
20 | .container-headline-data-loading {
21 | display: flex;
22 | flex: 4;
23 | justify-content: flex-start;
24 | align-items: center;
25 | position: relative;
26 | }
27 |
28 | .container-headline-data {
29 | display: flex;
30 | flex: 4;
31 | justify-content: flex-start;
32 | align-items: center;
33 | position: relative;
34 | }
35 |
36 | .container-headline-data h3, .container-headline-data-loading h3 {
37 | color: var(--almost-white);
38 | padding-right: 35px;
39 | font-size: 1.2em;
40 | }
41 |
42 | .container-headline-data h3:last-child {
43 | padding-right: 0;
44 | }
45 |
46 | .container-headline-data .headline-performance-green {
47 | color: var(--performance-green);
48 | }
49 |
50 | .container-headline-data .headline-performance-red {
51 | color: var(--performance-red);
52 | }
53 |
54 | .dropdown-button-wrapper {
55 | display: flex;
56 | flex: 1;
57 | justify-content: flex-end;
58 | align-items: center;
59 | margin-top: 7px;
60 | }
61 |
62 | .summary-table-container {
63 | display: none;
64 | transform-origin: top center;
65 | padding: 0px 20px 20px 20px;
66 | }
--------------------------------------------------------------------------------
/client/src/components/DetailsTable/DetailsTable.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 |
3 | .field-details-tx-table {
4 | display: grid;
5 | grid-template-columns: 1fr;
6 | grid-template-rows: auto;
7 | grid-template-areas:
8 | "headers"
9 | "tx-rows"
10 | }
11 |
12 | .details-tx-table-rows {
13 | display: flex;
14 | flex-direction: column-reverse;
15 | }
16 |
17 | .details-tx-table-headers, .tx-table-single-row {
18 | display: grid;
19 | grid-template-columns: 1fr 1.2fr 1fr 1.3fr 0.7fr 0.2fr;
20 | grid-template-rows: auto;
21 | justify-content: start;
22 | padding: 10px 20px;
23 | }
24 |
25 | .details-tx-table-headers {
26 | color: var(--transp-offwhite);
27 | font-size: 0.8em;
28 | }
29 |
30 | .tx-table-single-row {
31 | background-color: var(--almost-white);
32 | margin-bottom: 10px;
33 | border-radius: 10px;
34 | }
35 |
36 | .tx-table-single-row p {
37 | font-size: 0.8em;
38 | color: var(--dark-grey-font);
39 | }
40 |
41 | .transaction-details-value:before,
42 | .transaction-details-balance-value:before {
43 | color: var(--dark-grey-font);
44 | font-size: 0.8em;
45 | content: '$';
46 | }
47 |
48 | .transaction-details-link {
49 | justify-self: end;
50 | align-self: end;
51 | margin-top: 1.5px;
52 | }
53 |
54 | .transaction-details-link img {
55 | max-height: 13px;
56 | cursor: pointer;
57 | }
58 |
59 |
60 | .bloop {
61 | overflow: hidden;
62 | margin-bottom: 5px;
63 | line-height: 60px;
64 | }
65 | .bloop-cell-header {
66 | text-align: left;
67 | }
68 |
69 | .bloop-cell-value {
70 | font-size: 16px;
71 | font-weight: 700;
72 | }
--------------------------------------------------------------------------------
/server/controllers/tokens.js:
--------------------------------------------------------------------------------
1 | const Tokens = require ('../models/tokens');
2 | const path = require('path');
3 | const helpers = require('./helpers');
4 |
5 | async function getTokens (req, res) {
6 | try {
7 | const tokens = await Tokens.getTokens();
8 | res.status = 200;
9 | res.send(tokens);
10 | } catch (err) {
11 | console.error(`Error at ${path.basename(__dirname)}/${path.basename(__filename)} ${err}`);
12 | res.sendStatus(500);
13 | }
14 | }
15 |
16 | async function getUserFieldTokens (req, res) {
17 | try {
18 | tokenIds = JSON.parse(req.params.tokenIds)
19 | const {seedTokens, cropTokens} = tokenIds;
20 | const seedTokenQuery = helpers.generateFieldTokenQuery(seedTokens)
21 | const cropTokenQuery = helpers.generateFieldTokenQuery(cropTokens)
22 | const returnedTokens = {};
23 |
24 | if (seedTokenQuery.length) {
25 | const returnedSeed = await Tokens.selectUserFieldTokens(seedTokenQuery);
26 | returnedTokens.seedTokens = returnedSeed;
27 | }
28 | if (cropTokenQuery.length) {
29 | returnedCrop = await Tokens.selectUserFieldTokens(cropTokenQuery);
30 | returnedTokens.cropTokens = returnedCrop;
31 | }
32 | if (Object.keys(returnedTokens).length) {
33 | res.status = 200;
34 | res.send(returnedTokens);
35 | } else {
36 | res.sendStatus(204);
37 | }
38 |
39 | } catch(err) {
40 | console.error(`Error at ${path.basename(__dirname)}/${path.basename(__filename)} ${err}`);
41 | res.sendStatus(500);
42 | }
43 | }
44 |
45 | module.exports = {
46 | getTokens,
47 | getUserFieldTokens,
48 | }
--------------------------------------------------------------------------------
/client/src/components/Nav/Nav.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 |
3 | nav {
4 | display: flex;
5 | align-items: center;
6 | justify-content: center;
7 | margin: 0px 0 40px 0;
8 | padding: 30px 80px 20px 50px;
9 | }
10 |
11 | .nav-logo {
12 | flex: 1;
13 | display: flex;
14 | justify-content: flex-start;
15 | align-items: flex-start;
16 | position: relative;
17 | max-height: 45px;
18 | min-width: 165px;
19 | }
20 |
21 | .nav-logo img {
22 | align-self: flex-start;
23 | justify-self: flex-start;
24 | height: 45px;
25 | width: 165px;
26 | cursor: pointer;
27 | }
28 |
29 | .nav-items {
30 | flex: 3;
31 | display: flex;
32 | justify-content: flex-end;
33 | }
34 |
35 | .nav-items p, .nav-items a {
36 | font-size: 15px;
37 | font-weight: normal;
38 | color: white;
39 | cursor: pointer;
40 | }
41 |
42 | .nav-links {
43 | display: flex;
44 | align-items: center;
45 | }
46 |
47 | .nav-links p, .nav-links a {
48 | margin-right: 30px;
49 | }
50 |
51 | .nav-address-button {
52 | display: flex;
53 | align-items: center;
54 | padding: 6px 15px;
55 | border-radius: 30px;
56 | box-shadow: 1px 1px 3px 0px rgba(0,0,0,0.5);
57 | cursor: pointer;
58 | background-color: var(--transp-grey-background);
59 | }
60 |
61 | .nav-address-button p {
62 | margin-right: 8px;
63 | }
64 |
65 | .nav-address-button canvas {
66 | border-radius: 100%;
67 | box-shadow: 1px 1px 3px 0px rgba(0,0,0,0.5);
68 | }
69 |
70 | .info-modal-container {
71 | z-index: 1;
72 | visibility: collapse;
73 | opacity: 0;
74 | transition: opacity ease-in 300ms, visibility linear 300ms;
75 | }
--------------------------------------------------------------------------------
/client/src/containers/MyAssets/MyAssets.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 | @import '../../CSS/animations.css';
3 |
4 |
5 | .myassets-summary {
6 | display: flex;
7 | position: relative;
8 | flex-direction: column;
9 | justify-content: center;
10 | }
11 |
12 | .summary-overview-cards-container {
13 | display: flex;
14 | width: 80%;
15 | align-self: center;
16 | margin: 20px 0 50px 0;
17 | justify-content: center;
18 | }
19 |
20 | @media (min-width: 640px) {
21 | .summary-overview-cards-container .overview-card:first-child {
22 | margin-right: 80px;
23 | }
24 | }
25 |
26 | @media (max-width: 639px) {
27 | .summary-overview-cards-container {
28 | flex-direction: column;
29 | align-items: center;
30 | margin-top: 5px;
31 | }
32 |
33 | .summary-overview-cards-container .overview-card:first-child {
34 | margin-bottom: 30px;
35 | }
36 | }
37 |
38 | .account-overview {
39 | width: 83%;
40 | align-self: center;
41 | padding: 0px 20px 10px 20px;
42 | box-shadow: 0 6px 2px -6px var(--periwinkle);
43 | }
44 |
45 | .account-overview h1 {
46 | font-size: 1.5em;
47 | font-weight: 300;
48 | color: var(--transp-offwhite);
49 | }
50 |
51 | .summary-container-sup {
52 | position: relative;
53 | display: flex;
54 | flex-direction: column;
55 | align-self: center;
56 | align-items: center;
57 | width: 80%;
58 | }
59 |
60 | .summary-container {
61 | display: flex;
62 | flex-direction: column;
63 | width: 100%;
64 | justify-content: center;
65 | border-radius: 10px;
66 | margin: 20px 50px 0 50px;
67 | padding: 0px 20px 0px 20px;
68 | background-color: var(--opaque-grey)
69 | }
70 |
71 | .summary-farming {
72 | margin-bottom: 50px;
73 | }
--------------------------------------------------------------------------------
/client/src/components/SummaryTable/SummaryTable.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './SummaryTable.css';
3 | import TokenCell from '../TokenCell/TokenCell';
4 | import { useHistory } from 'react-router-dom';
5 | import helpers from '../../helpers';
6 |
7 |
8 | export default function SummaryTable ({headers, userValues, tableName, currencyCells, setCurrentDetail}) {
9 | const history = useHistory();
10 |
11 | function handleClick(e, rowValues) {
12 | setCurrentDetail(rowValues[0]);
13 | const sanitisedString = helpers.urlStringSanitiser(rowValues[0]);
14 |
15 | if (tableName === 'holding') history.push(`/token/${sanitisedString}`);
16 | if (tableName === 'farming') history.push(`/farming/${sanitisedString}`);
17 | if (tableName === 'earning') history.push(`/earning/${sanitisedString}`);
18 | }
19 |
20 | return (
21 |
22 |
23 |
24 | {headers.map(header => )}
25 |
26 |
27 |
28 | {userValues.map((rowValues, rowIndex) => {
29 | return (
30 | handleClick(e, rowValues)}>
31 | {rowValues.map((value, cellIndex) => {
32 | return (
33 |
34 | )
35 | })}
36 |
37 | )
38 | })}
39 |
40 |
41 | )
42 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/balanceContractCreator.js:
--------------------------------------------------------------------------------
1 | import { ethers } from 'ethers';
2 | import provider from './ethProvider';
3 |
4 | /**
5 | * @func createContract creates an instance of a new ethers contract interface
6 | * @param {collection} array of tracked tokens or fields
7 | * @returns {object} collection with new contract interfaces
8 | */
9 | function createBalanceContracts (collection) {
10 | const collectionWithContracts = [];
11 |
12 | collection.forEach(element => {
13 |
14 | //for Fields
15 | if (element.fieldId) {
16 | const { contractAddresses } = element;
17 | //TODO: enforce unicity of filter here?
18 | let balanceAddress = contractAddresses.filter(address => address.addressTypes.includes('balance'));
19 | if (balanceAddress.length === 1) {
20 | balanceAddress = balanceAddress[0]
21 | } else {
22 | throw new Error('Error identifying balance address, may not exist or not be unique - createBalanceContracts()');
23 | }
24 | element.fieldContracts = {
25 | balanceContract: {
26 | contract: new ethers.Contract(balanceAddress.address, balanceAddress.contractInterface.abi, provider),
27 | decimals: balanceAddress.contractInterface.decimals
28 | }
29 | }
30 | }
31 |
32 | //for tokens
33 | else if (element.name !== 'Eth') {
34 | const { address, contractInterface } = element;
35 | element.tokenContract = {
36 | contract: new ethers.Contract(address, contractInterface.abi, provider),
37 | decimals: contractInterface.decimals
38 | }
39 | }
40 |
41 | collectionWithContracts.push(element);
42 | })
43 |
44 | return collectionWithContracts;
45 | }
46 |
47 | export default createBalanceContracts;
48 |
49 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | -- Deprecated monorepo --
2 |
3 | # SimpleFi
4 |
5 |
6 |
7 |
8 |
9 | SimpleFi makes it easy to manage your decentralised finance investment portfolio.
10 |
11 |
12 | ## Screenshots
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | ## Getting started
22 |
23 | 1. Clone the repo
24 |
25 | ```
26 | git https://github.com/raphael-mazet/SimpleFi.git
27 | ```
28 |
29 | 2. Create a .env file in the Prisma directory
30 | ```
31 | DB_PORT=
32 | DB_USERNAME=
33 | DB_PASSWORD=
34 | DB_HOST=
35 | DB_NAME=
36 | DATABASE_URL = postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
37 | ```
38 |
39 | 3. Start the backend server
40 | ```
41 | cd server/
42 | npm install
43 | nodemon index.js
44 | ```
45 |
46 | 4. start the client server
47 | ```
48 | cd client/
49 | npm install
50 | npm start
51 | ```
52 |
53 |
54 | ## Built with
55 |
56 | * [Express](https://expressjs.com/) - fast, unopinionated, minimalist web framework for Node.js
57 | * [React](https://reactjs.org/) - javaScript library for building user interfaces
58 | * [PostgreSQL](https://www.postgresql.org/) - open source relational database
59 | * [Prisma](https://www.prisma.io/) - open source database toolkit
60 | * [ethers.js](https://docs.ethers.io/v5/) - lightweight javaScript library to interact with the Ethereum blockchain
61 |
62 |
63 | ## Contributing
64 |
65 | Improvements and remixes are welcome.
66 |
67 |
68 | ## Author
69 |
70 | Raphaël Mazet - [Github](https://github.com/raphael-mazet) - [LinkedIn](https://www.linkedin.com/in/raphael-mazet/)
71 |
--------------------------------------------------------------------------------
/client/src/helpers/detailsTableHelper.js:
--------------------------------------------------------------------------------
1 | export default function extractTempFieldDetailsCells(tx) {
2 | const cellValues = [];
3 | const {action, amount, balEffect} = addTxTypeAndAmount(tx);
4 | // @dev: only reward claim txs will have a defined value for pricePerReceiptToken
5 | const {txDate, pricePerToken, pricePerReceiptToken} = tx;
6 |
7 | cellValues.push(txDate.toLocaleString('en-GB').split(',')[0]);
8 | cellValues.push(action);
9 | cellValues.push(amount);
10 | cellValues.push(balEffect);
11 | cellValues.push({pricePerToken, pricePerReceiptToken})
12 |
13 | return cellValues;
14 | }
15 |
16 | function addTxTypeAndAmount(tx) {
17 | const { txIn, txOut, staked, unstaked, stakingAmount, unstakingAmount, rewardAmount} = tx;
18 | //@dev: txIn(out) are earning field txs
19 | if (txIn || txIn === 0) return {action: 'accumulated', amount: txIn, balEffect: 'plus'};
20 | else if (txOut || txOut === 0) return {action: 'exited', amount: txOut, balEffect: 'minus'};
21 | /* @dev: (un)staked are an earning field tx where its receipt token interacts with a
22 | farming field and therefore has no impact on the underlying earning field balance */
23 | else if (staked || staked === 0) return {action: 'staked', amount: staked, balEffect: 'neutral'};
24 | else if (unstaked || unstaked === 0) return {action: 'unstaked', amount: unstaked, balEffect: 'neutral'};
25 | //@dev (un)stakingAmount are farming field txs and therefore modify the farming field's balance
26 | else if (stakingAmount || stakingAmount === 0) return {action: 'staked', amount: stakingAmount, balEffect: 'plus'};
27 | else if (unstakingAmount || unstakingAmount === 0) return {action: 'unstaked', amount: unstakingAmount, balEffect: 'minus'};
28 | else if (rewardAmount || rewardAmount === 0) return {action: 'claimed', amount: rewardAmount, balEffect: 'neutral'}
29 | };
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getAPYs/farmingAPYs/getSnxFarmingAPY.js:
--------------------------------------------------------------------------------
1 | import { ethers } from 'ethers';
2 | import provider from '../../ethProvider';
3 |
4 | //cache
5 | const rewardRateAndAPY = {};
6 |
7 | /**
8 | *
9 | * @param {Object} rewardRateAddress - contract data from which to query the field's reward rate
10 | * @param {Object} field - currently analysed field
11 | * @param {Array} userTokenPrices - all tracked token prices
12 | * @dev - this getter assumes there is just one crop token and one seed token per SNX farming field
13 | * - this assumption is used when defining snxDecimals, the seed price and the crop price
14 | * @return {Number} - the field's APY
15 | */
16 | async function getSnxFarmingAPY(rewardRateAddress, field, userTokenPrices) {
17 |
18 | if (rewardRateAndAPY[field.name]) return rewardRateAndAPY[field.name].APY;
19 |
20 | rewardRateAndAPY[field.name] = {};
21 | const { address, contractInterface } = rewardRateAddress;
22 | const snxDecimals = field.cropTokens[0].contractInterface.decimals;
23 | const secondsPerYear = 3.154e7;
24 | const contract = new ethers.Contract(address, contractInterface.abi, provider);
25 |
26 | //TODO: add logic around timeperiod ending - contract.DURATION()
27 |
28 | //define annual reward
29 | const rewardRate = await contract.rewardRate();
30 | rewardRateAndAPY[field.name].rewardRate = rewardRate;
31 | const annualPayout = Number(ethers.utils.formatUnits(rewardRate, snxDecimals)) * secondsPerYear;
32 |
33 | //define APY
34 | const { totalSupply } = field;
35 | const seedPrice = userTokenPrices[field.seedTokens[0].name].usd;
36 | const cropPrice = userTokenPrices[field.cropTokens[0].name].usd;
37 | const APY = (annualPayout * cropPrice) / (totalSupply * seedPrice);
38 | rewardRateAndAPY[field.name].APY = APY;
39 | return APY;
40 | }
41 |
42 | export default getSnxFarmingAPY;
--------------------------------------------------------------------------------
/client/src/helpers/index.js:
--------------------------------------------------------------------------------
1 | import {
2 | populateFieldTokensFromCache,
3 | addLockedTokenBalances,
4 | addUnclaimedBalances,
5 | addFieldSuppliesAndReserves,
6 | addStakedFieldBalances,
7 | addFieldInvestmentValues,
8 | amendModal
9 | } from './appHelpers'
10 | import extractSummaryHoldingValues from './myAssetsHelpers/tokenHelpers';
11 | import { extractSummaryFieldValues } from './myAssetsHelpers/fieldHelpers';
12 | import toggleDropdown from './myAssetsHelpers/dropdownHelper';
13 | import {
14 | findFieldAddressType,
15 | combineFieldSuppliesAndReserves,
16 | sortLiquidityTxs,
17 | sortFarmingTxs,
18 | createWhitelist,
19 | calcEarningROI,
20 | calcFarmingROI
21 | } from './ethHelpers';
22 | import extractTempFieldDetailsCells from './detailsTableHelper';
23 | import urlStringSanitiser from './urlStringSanitiser';
24 | import formatHeadlines from './summaryBoxHelper';
25 | import {extractTotalTokenBalance} from './tokenDetailsHelper';
26 | import {
27 | extractDetailsPieChartValues,
28 | extractDetailsBarChartValues,
29 | chartCallbacks
30 | } from './detailsChartHelpers';
31 | import calcCombinedROI from './earningFieldDetailsHelpers';
32 |
33 | //eslint-disable-next-line import/no-anonymous-default-export
34 | export default {
35 | populateFieldTokensFromCache,
36 | addUnclaimedBalances,
37 | addLockedTokenBalances,
38 | addFieldSuppliesAndReserves,
39 | addStakedFieldBalances,
40 | addFieldInvestmentValues,
41 | amendModal,
42 | extractSummaryHoldingValues,
43 | extractSummaryFieldValues,
44 | toggleDropdown,
45 | findFieldAddressType,
46 | combineFieldSuppliesAndReserves,
47 | sortLiquidityTxs,
48 | sortFarmingTxs,
49 | createWhitelist,
50 | calcEarningROI,
51 | calcFarmingROI,
52 | extractTempFieldDetailsCells,
53 | urlStringSanitiser,
54 | formatHeadlines,
55 | extractTotalTokenBalance,
56 | extractDetailsPieChartValues,
57 | extractDetailsBarChartValues,
58 | chartCallbacks,
59 | calcCombinedROI
60 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getROIs/earningROIs/getUserLiquidityHistory.js:
--------------------------------------------------------------------------------
1 | import getCurveLiquidityHistory from './getCurveLiquidityHistory';
2 | import getUniswapLiquidityHistory from './getUniswapLiquidityHistory';
3 | import helpers from '../../../../helpers';
4 |
5 | /**
6 | *
7 | * @param {Array} trackedFields - all tracked fields
8 | * @param {Object} field - currently analysed earning field
9 | * @param {Object} receiptToken - current field's receipt token
10 | * @param {Array} userReceiptTokenTxs - all user transactions involving receipt token
11 | * @param {String} userAccount - user's ethereum account
12 | * @dev switch is based on the field's protocol name, assuming liquidity history
13 | * extraction method is the same for all of a protocol's earning fields
14 | * @return {Array} - a list of user transactions ready to be processed by calcROI helper: {
15 | * pricePerToken: at the time of the transaction
16 | * one of four tx types: txIn, txOut, staked or unstaked (one filled with value, others undefined)
17 | * txDate: date object
18 | * tx: object containing all tx details (content can vary based on source)
19 | * }
20 | */
21 | async function getUserLiquidityHistory(trackedFields, field, receiptToken, userReceiptTokenTxs, userAccount) {
22 |
23 | const whitelist = helpers.createWhitelist(trackedFields, field);
24 | let liquidityHistory;
25 |
26 | switch (field.protocol.name) {
27 |
28 | case "Curve":
29 | //@dev: this function contains a array.map of multiple calls to coinGecko, hence the use of a promise.all in main func
30 | liquidityHistory = await getCurveLiquidityHistory(field, receiptToken, userReceiptTokenTxs, userAccount, whitelist)
31 | break;
32 |
33 | case "Uniswap":
34 | liquidityHistory = await getUniswapLiquidityHistory(field, userReceiptTokenTxs, userAccount, whitelist)
35 | break;
36 |
37 | default:
38 | break;
39 | }
40 | return liquidityHistory;
41 | }
42 |
43 | export default getUserLiquidityHistory;
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getUserBalances.js:
--------------------------------------------------------------------------------
1 | import { ethers } from 'ethers';
2 | import provider from './ethProvider';
3 |
4 | /**
5 | * @func getUserBalance retrieves balance of an ethereum account's tokens and stakes
6 | * @param {account} string user account address for which balance is requested
7 | * @param {contract} string token contract (optional - defaults to Eth)
8 | * @returns {string} account balance
9 | * @dev not all contracts specify decimals with which to parse balance, so defaults to 18
10 | */
11 | async function getUserBalance (account, targetContract) {
12 | if (!targetContract) {
13 | const balance = await provider.getBalance(account);
14 | return Number(ethers.utils.formatEther(balance));
15 |
16 | } else {
17 | const { contract, decimals } = targetContract.balanceContract || targetContract;
18 | let balance = await contract.balanceOf(account);
19 | balance = Number(ethers.utils.formatUnits(balance, decimals));
20 |
21 | return balance;
22 | }
23 | }
24 |
25 | //TODO: documentation
26 | //TODO: consider one call to Etherscan for token balances
27 | function getAllUserBalances (account, fieldOrTokenArr) {
28 | const balancePromises = Promise.all(
29 | fieldOrTokenArr.map(
30 | async fieldOrToken => {
31 | let contract;
32 | if (fieldOrToken.tokenId) {
33 | contract = fieldOrToken.tokenContract;
34 | } else {
35 | //TODO: destructure one further so only contract is passed to getUserBalance and avoid ||
36 | contract = fieldOrToken.fieldContracts;
37 | }
38 | const userBalance = await getUserBalance(account, contract);
39 | if(userBalance) {
40 | return { ...fieldOrToken, userBalance }
41 | }
42 | }
43 | )
44 | )
45 | //filter undefined value from map
46 | .then(tokensWithBalances => tokensWithBalances.filter(token => token))
47 | return balancePromises;
48 | }
49 |
50 | export {
51 | getUserBalance,
52 | getAllUserBalances,
53 | }
54 |
55 |
--------------------------------------------------------------------------------
/client/src/containers/TokenDetails/TokenDetails.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 |
3 | .token-details {
4 | margin: 20px 50px;
5 | position: relative;
6 | }
7 |
8 | .token-details h2, .token-details p, .token-details a {
9 | color: var(--almost-white);
10 | }
11 |
12 | .token-details-titles {
13 | display: flex;
14 | flex-direction: column;
15 | margin-bottom: 20px;
16 | }
17 |
18 | .token-details-titles h2 {
19 | font-size: 1.5em;
20 | margin-bottom: 20px;
21 | }
22 |
23 | .token-details-titles p {
24 | line-height: 1.8em;
25 | }
26 |
27 | .token-details-titles a {
28 | cursor: pointer;
29 | }
30 |
31 | .token-title-header {
32 | font-weight: 500;
33 | }
34 |
35 | .token-details-overviews {
36 | display: flex;
37 | justify-content: center;
38 | margin-right: var(--details-page-margin-right);
39 | padding-left: 50px;
40 | height: 300px;
41 | }
42 |
43 | .token-details-numbers {
44 | display: flex;
45 | flex: 1;
46 | flex-direction: column;
47 | align-items: center;
48 | justify-content: space-between;
49 | }
50 |
51 | .token-overview {
52 | display: flex;
53 | margin-top: 20px;
54 | padding: 20px 30px;
55 | flex-direction: column;
56 | align-items: center;
57 | justify-content: space-around;
58 | border-radius: 10px;
59 | box-shadow: 0 0px 2px 0px var(--periwinkle);
60 | border-width: 1px;
61 | }
62 |
63 | .token-overview h2, .token-source-container h2 {
64 | font-size: 1.2em;
65 | line-height: 2em;
66 | margin-bottom: 10px;
67 | }
68 |
69 | .token-overview p {
70 | font-size: 1.2em;
71 | }
72 |
73 | .token-source-container {
74 | display: flex;
75 | flex: 2;
76 | flex-direction: column;
77 | position: relative;
78 | align-items: flex-start;
79 | }
80 |
81 | .token-source-container h2 {
82 | margin-top: 15px;
83 | margin-bottom: 15px;
84 | }
85 |
86 | .token-source-chart {
87 | position: relative;
88 | height: 250px;
89 | width: 500px;
90 | }
91 |
92 | .token-transactions-table {
93 | margin: 10px var(--token-details-margin-right) 0 20px;
94 | border-width: 1px;
95 | border-radius: 10px;
96 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getAPYs/farmingAPYs/getSecondaryFieldAPYs.js:
--------------------------------------------------------------------------------
1 | import getFarmingAPYs from './getFarmingAPYs';
2 | import provider from '../../ethProvider';
3 | import { ethers } from 'ethers';
4 |
5 | export default async function getSecondaryFieldAPYs(primaryField, userTokenPrices, primaryCropIndex) {
6 | const cropAPYs = [];
7 | let cropIndex = 0;
8 |
9 | for (let cropToken of primaryField.cropTokens) {
10 | if (cropIndex !== primaryCropIndex) {
11 | //identify which field the target secondary crop comes from
12 | const { secondaryField } = primaryField.secondaryFields.find(secondaryField => {
13 | return secondaryField.secondaryField.cropTokens.some(secondaryCropToken => {
14 | return secondaryCropToken.token.tokenId === cropToken.tokenId
15 | })
16 | })
17 |
18 | //reformat the secondary field to be processed by getFarmingAPY
19 | secondaryField.cropTokens.forEach(targetCropToken => {
20 | targetCropToken.name = targetCropToken.token.name;
21 | targetCropToken.contractInterface = targetCropToken.token.contractInterface;
22 | });
23 | secondaryField.seedTokens.forEach(targetSeedToken => {
24 | targetSeedToken.name = targetSeedToken.token.name;
25 | });
26 |
27 | //add secondaryFieldTotalSupply for use in APY calculation
28 | //CHECK: worth checking from fieldSuppliesAndReserves cache in App.js?
29 | const {address, contractInterface} = secondaryField.contractAddresses.find(contractAddress => contractAddress.addressTypes.includes('balance'));
30 | const secondaryFieldBalanceContract = new ethers.Contract(address, contractInterface.abi, provider);
31 | const secondarySupplyBigInt = await secondaryFieldBalanceContract.totalSupply();
32 | secondaryField.totalSupply = Number(ethers.utils.formatUnits(secondarySupplyBigInt, cropToken.contractInterface.decimals));
33 |
34 | const cropAPY = await getFarmingAPYs(secondaryField, userTokenPrices);
35 | cropAPYs.push({cropAPY, cropToken, secondaryField});
36 | }
37 | cropIndex ++;
38 | }
39 | return cropAPYs;
40 | }
41 |
--------------------------------------------------------------------------------
/client/src/containers/TokenDetails/TokenDetails.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect} from 'react';
2 | import './TokenDetails.css';
3 | import DetailsPieChart from '../../components/DetailsPieChart/DetailsPieChart';
4 | import helpers from '../../helpers';
5 |
6 | export default function TokenDetails({name, userTokens, userTokenPrices, history}) {
7 |
8 | const [currentToken] = useState(userTokens.find(field => field.name === name));
9 | const [totalBalance, setTotalBalance] = useState(0);
10 | const [totalValue, setTotalValue] = useState(0);
11 |
12 | useEffect(() => {
13 | window.scrollTo(0, 0);
14 | if (name) {
15 | const totalTokenBalance = helpers.extractTotalTokenBalance(currentToken);
16 | setTotalBalance(totalTokenBalance);
17 | setTotalValue(totalTokenBalance * userTokenPrices[name].usd);
18 | }
19 | // eslint-disable-next-line react-hooks/exhaustive-deps
20 | }, [currentToken]);
21 |
22 | if (!name) {
23 | history.push('/dashboard');
24 | return (<>>)
25 | }
26 |
27 | return(
28 |
29 |
33 |
34 |
35 |
36 |
37 |
Total balance
38 |
{Number(totalBalance.toFixed(2)).toLocaleString()}
39 |
40 |
41 |
42 |
Current value
43 |
${Number(totalValue.toFixed(2)).toLocaleString()}
44 |
45 |
46 |
47 |
48 |
Source of funds
49 |
50 |
51 |
52 |
53 |
54 |
55 | )
56 | }
--------------------------------------------------------------------------------
/client/src/helpers/myAssetsHelpers/tokenHelpers.js:
--------------------------------------------------------------------------------
1 | export default function extractSummaryHoldingValues (userTokens, userTokenPrices) {
2 | const summaryTableValues = {
3 | baseTokens: [],
4 | receiptTokens: []
5 | };
6 | const overviewValues = {
7 | totalInvested: 0,
8 | totalUnclaimed: 0,
9 | totalValue: 0
10 | };
11 |
12 | userTokens.forEach(token => {
13 | let lockedBalance = 0;
14 | let unclaimedBalance = 0;
15 | let combinedBalance = 0;
16 | let lockedPercent = 0;
17 | const tokenPrice = userTokenPrices[token.name].usd;
18 | const formatter = new Intl.NumberFormat("en-US", {style: 'percent'});
19 |
20 | if (token.lockedBalance) {
21 | lockedBalance = token.lockedBalance.reduce((acc, curr) => acc + curr.balance, 0);
22 | }
23 | if (token.unclaimedBalance) {
24 | unclaimedBalance = token.unclaimedBalance.reduce((acc, curr) => acc + curr.balance, 0);
25 | }
26 | if (token.userBalance) {
27 | combinedBalance = token.userBalance + lockedBalance + unclaimedBalance;
28 | lockedPercent = formatter.format((lockedBalance + unclaimedBalance) / combinedBalance);
29 | } else {
30 | combinedBalance = lockedBalance + unclaimedBalance;
31 | lockedPercent = formatter.format(1);
32 | }
33 |
34 | if (token.isBase) {
35 | summaryTableValues.baseTokens.push([
36 | token.name,
37 | Number(combinedBalance.toFixed(2)).toLocaleString(),
38 | lockedPercent,
39 | Number(tokenPrice.toFixed(2)).toLocaleString(),
40 | Number((combinedBalance * tokenPrice).toFixed(2)).toLocaleString()
41 | ]);
42 | overviewValues.totalInvested += lockedBalance * tokenPrice;
43 | overviewValues.totalUnclaimed += unclaimedBalance * tokenPrice;
44 | overviewValues.totalValue += combinedBalance * tokenPrice
45 |
46 | } else {
47 | summaryTableValues.receiptTokens.push([
48 | token.name,
49 | Number(combinedBalance.toFixed(2)).toLocaleString(),
50 | lockedPercent,
51 | Number(tokenPrice.toFixed(2)).toLocaleString(),
52 | Number((combinedBalance * tokenPrice).toFixed(2)).toLocaleString()
53 | ]);
54 | }
55 | });
56 |
57 | return {summaryTableValues, overviewValues};
58 | }
59 |
60 | export {
61 | extractSummaryHoldingValues,
62 | }
63 |
--------------------------------------------------------------------------------
/client/src/components/DetailsPieChart/DetailsPieChart.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect, useRef} from 'react';
2 | import './DetailsPieChart.css';
3 | import helpers from '../../helpers';
4 | const Chart = require('chart.js');
5 |
6 | export default function DetailsChart({data, type}) {
7 |
8 | const [tableData, setTableData] = useState({data: [], fill:[], labels: [], other: []});
9 | const chartRef = useRef(null);
10 |
11 | useEffect(() => {
12 | setTableData(helpers.extractDetailsPieChartValues(data, type))
13 | }, [data, type]);
14 |
15 | useEffect(() => {
16 | new Chart(chartRef.current, {
17 | type: "pie",
18 | data: {
19 | datasets: [{
20 | data: tableData.data,
21 | backgroundColor: tableData.fill,
22 | labels: tableData.labels,
23 | other: tableData.other,
24 | }],
25 | labels: tableData.labels
26 | },
27 | options: {
28 | responsive: true,
29 | maintainAspectRatio: false,
30 | layout: {
31 | padding: {
32 | left: 0
33 | }
34 | },
35 | tooltips: {
36 | yPadding: 10,
37 | xPadding: 10,
38 | titleSpacing: 100,
39 | bodySpacing: 5,
40 | callbacks: {
41 | label: function(tooltipItem, data) {
42 | if (type === 'farming') {
43 | return helpers.chartCallbacks.label.farming(tooltipItem, data);
44 | } else {
45 | return Chart.defaults.doughnut.tooltips.callbacks.label(tooltipItem, data);
46 | }
47 | },
48 | title: function(tooltipItem, data) {
49 | if (type === 'farming') {
50 | return helpers.chartCallbacks.title.farming(tooltipItem, data);
51 | }
52 | }
53 | }
54 | },
55 | legend: {
56 | labels: {
57 | fontColor: '#FFFAFA'
58 | },
59 | position: 'right',
60 | align: 'center',
61 | fontFamily: "'Roboto', 'Noto Sans', 'Ubuntu', 'Droid Sans', 'Helvetica Neue'",
62 | }
63 | }
64 | });
65 | }, [tableData]); // eslint-disable-line react-hooks/exhaustive-deps
66 |
67 | return (
68 |
69 | )
70 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getROIs/earningROIs/getCurveLiquidityHistory.js:
--------------------------------------------------------------------------------
1 | import { getOneCurvePoolRawData } from '../../protocolQueries';
2 | import getHistoricalPrice from '../../../coinGecko/getHistoricalPrice';
3 | import helpers from '../../../../helpers';
4 |
5 | /**
6 | *
7 | * @param {Object} field - current Curve earning (liquidity pool) field
8 | * @param {Object} receiptToken - fields receipt token, used to track user holding changes
9 | * @param {Array} userReceiptTokenTxs - all transactions involving user's receipt token
10 | * @param {String} userAccount - user's Ethereum account
11 | * @param {Array} whitelist - array of staking addresses, to avoid staking/unstaking receipt tokens being couinted as a realised profit/loss or new investment
12 | */
13 | async function getCurveLiquidityHistory(field, receiptToken, userReceiptTokenTxs, userAccount, whitelist) {
14 | const timeFormatter = new Intl.DateTimeFormat('en-GB');
15 | const historicalCurveStats = await getOneCurvePoolRawData(field.name);
16 |
17 | const liquidityHistory = userReceiptTokenTxs.map(async tx => {
18 | const txDate = new Date(Number(tx.timeStamp) * 1000);
19 | //@dev: simplify date to just day/month/year (no time) to find corresponding day in curve snapshot data
20 | const compDate = timeFormatter.format(new Date(Number(tx.timeStamp) * 1000));
21 | const historicalStat = historicalCurveStats.find(day => compDate === timeFormatter.format(new Date(Number(day.timestamp) * 1000)));
22 |
23 | const geckoDateformat = compDate.replace(/\//gi, '-')
24 | let fieldHistReserveValue = 0;
25 |
26 | for (let seed of field.seedTokens) {
27 | const histSeedValue = await getHistoricalPrice (seed.priceApi, geckoDateformat);
28 | const decimaledReserve = historicalStat.balances[seed.seedIndex]/Number(`1e${seed.tokenContract.decimals}`);
29 | fieldHistReserveValue += histSeedValue * decimaledReserve;
30 | }
31 | //TODO: check impact of split admin fees and use of virtual price
32 | const pricePerToken = fieldHistReserveValue / (historicalStat.supply / Number(`1e${receiptToken.tokenContract.decimals}`));
33 | const {txIn, txOut, staked, unstaked} = helpers.sortLiquidityTxs(tx, userAccount, whitelist);
34 |
35 | return {tx, txDate, pricePerToken, txIn, txOut, staked, unstaked}
36 | })
37 |
38 | return liquidityHistory;
39 | }
40 |
41 | export default getCurveLiquidityHistory;
--------------------------------------------------------------------------------
/client/src/components/Nav/Nav.js:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useRef, useState} from 'react';
2 | import './Nav.css';
3 | import Blockies from 'react-blockies';
4 | import InfoModal from '../../components/InfoModal/InfoModal';
5 | import simpleFiLogo from '../../assets/logos/simplefi-logotype.svg';
6 |
7 | export default function Nav ({splash, userAccount, history}) {
8 | const [infoModalContent, setInfoModalContent] = useState('');
9 | const aboutRef = useRef(null);
10 | const infoModalRef = useRef(null);
11 | const infoModalContentRef = useRef(null);
12 | const logoRef = useRef(null);
13 |
14 | function handleClick(e) {
15 | if (!infoModalContentRef.current.contains(e.target)) {
16 | if (aboutRef.current.contains(e.target) && infoModalRef.current.style.visibility !== 'visible') {
17 | setInfoModalContent('about');
18 | infoModalRef.current.style.visibility = 'visible';
19 | infoModalRef.current.style.opacity = 1;
20 | } else {
21 | infoModalRef.current.style.visibility = 'collapse';
22 | infoModalRef.current.style.opacity = 0;
23 | }
24 | }
25 | if (logoRef.current.contains(e.target)) {
26 | history.push('/');
27 | }
28 | }
29 |
30 | useEffect(() => {
31 | document.addEventListener('mousedown', handleClick);
32 | return () => {document.removeEventListener("mousedown", handleClick)}
33 | })
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 |
About
43 |
😊
44 |
Github
45 |
46 |
47 |
{userAccount.length ? `${userAccount[0].slice(0,6)}...${userAccount[0].slice(-4)}` : '0x0000...0000'}
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/client/src/containers/EarningFieldDetails/EarningFieldDetails.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 | @import '../../CSS/animations.css';
3 |
4 | .field-details {
5 | margin: 20px 100px 0 100px;
6 | }
7 |
8 | .field-details h2, h3, p {
9 | color: var(--almost-white);
10 | }
11 |
12 | .field-details-titles {
13 | display: flex;
14 | flex-direction: column;
15 | margin-bottom: 40px;
16 | }
17 |
18 | .field-details-titles h2 {
19 | font-size: 1.5em;
20 | margin-bottom: 20px;
21 | }
22 |
23 | .field-details-titles p {
24 | line-height: 1.8em;
25 | margin-left: 20px;
26 | font-weight: 600;
27 | }
28 |
29 | .field-title-header {
30 | font-weight: 300;
31 | margin-right: 10px;
32 | }
33 |
34 | .earning-details-toggle-roi {
35 | display: flex;
36 | align-items: center;
37 | justify-content: center;
38 | }
39 |
40 | .earning-details-toggle-roi h3 {
41 | font-size: 0.95em;
42 | color: var(--offwhite);
43 | margin-right: 5px;
44 | }
45 |
46 | .field-details-numbers {
47 | display: flex;
48 | justify-content: center;
49 | margin-top: 15px;
50 | margin-bottom: 60px;
51 | }
52 |
53 | .field-overview {
54 | display: flex;
55 | padding: 15px;
56 | flex-direction: column;
57 | align-items: center;
58 | justify-content: space-around;
59 | border-radius: 10px;
60 | box-shadow: 0 0px 2px 0px var(--periwinkle);
61 | border-width: 1px;;
62 | width: 170px;
63 | height: 140px;
64 | }
65 |
66 | .field-overview:last-child {
67 | margin-left: 40px;
68 | }
69 |
70 | .field-overview-header {
71 | text-align: center;
72 | }
73 |
74 | .field-overview-value {
75 | font-size: 1.2em;
76 | font-weight: bold;
77 | }
78 |
79 | .roi-pulse {
80 | animation: pulse 300ms;
81 | }
82 |
83 | .field-details-combined-roi {
84 | display: none;
85 | flex-direction: column;
86 | align-items: center;
87 | margin-right: var(--field-details-margin-right);
88 | margin-bottom: 15px;
89 | }
90 |
91 | .field-details-combined-roi h2 {
92 | margin-bottom: 15px;
93 | }
94 |
95 | .combined-roi-earnings-chart {
96 | position: relative;
97 | height: 250px;
98 | width: 500px;
99 | }
100 |
101 | .field-transactions {
102 | margin: 40px 0 0 0;
103 | padding-bottom: 50px;
104 | }
105 |
106 | .field-transactions h2 {
107 | margin-left: 20px;
108 | }
109 |
110 | .field-transactions-table {
111 | margin: 10px var(--details-page-margin-right) 0 20px;
112 | border-width: 1px;
113 | border-radius: 10px;
114 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getAPYs/farmingAPYs/getCurveFarmingAPY.js:
--------------------------------------------------------------------------------
1 | import { ethers } from 'ethers';
2 | import provider from '../../ethProvider';
3 | import { getTotalAnnualReward, getFieldRewardPercent } from '../../protocolQueries/curveQueries/getCurveGaugeConstants';
4 | import getSecondaryFieldAPYs from './getSecondaryFieldAPYs';
5 |
6 | /*
7 | define annual reward
8 | sCRV gauge will also be used for boost
9 | the reward rate is the the total crop reward given per second to all pool gauges (farming fields)
10 | the .inflation_rate() method produces the same result for all pool gauge contracts and is the same as the CRV token contract's .rate() method
11 | */
12 | async function getCurveFarmingAPY(rewardRateAddress, field, userTokenPrices) {
13 |
14 | const curveIndex = field.cropTokens.length === 1 ? 0 : field.cropTokens.findIndex(cropToken => cropToken.name === 'Curve');
15 | const curveDecimals = field.cropTokens[curveIndex].contractInterface.decimals;
16 | const rewardWeightAddress = field.contractAddresses.find(address => address.addressTypes.includes('rewardWeight'));
17 | const rewardRateContract = new ethers.Contract(rewardRateAddress.address, rewardRateAddress.contractInterface.abi, provider);
18 | const rewardWeightContract = new ethers.Contract(rewardWeightAddress.address, rewardWeightAddress.contractInterface.abi, provider);
19 |
20 | //TODO: add logic around timeperiod ending - contract.DURATION()
21 |
22 | const totalAnnualReward = await getTotalAnnualReward(rewardRateContract, curveDecimals);
23 | const fieldRewardPercent = await getFieldRewardPercent(rewardWeightContract, rewardRateAddress.address, curveDecimals);
24 |
25 | const curveAnnualPayout = totalAnnualReward * fieldRewardPercent;
26 | const { totalSupply } = field;
27 |
28 | //@dev: this assumes there is just one seed
29 | const seedPrice = userTokenPrices[field.seedTokens[0].name].usd;
30 |
31 | //get primary Curve APY
32 | //TODO: figure out the boost situation
33 | const cropPrice = userTokenPrices[field.cropTokens[curveIndex].name].usd;
34 | const curveCropAPY = (curveAnnualPayout * cropPrice) / (totalSupply * seedPrice);
35 |
36 | //additional crop token APYs
37 | if (curveIndex) {
38 | const additionalCropAPYs = await getSecondaryFieldAPYs(field, userTokenPrices, curveIndex);
39 | const secondaryAPY = additionalCropAPYs.reduce((acc, additionalAPY) => acc += additionalAPY.cropAPY, 0);
40 | return {
41 | combinedAPY: curveCropAPY + secondaryAPY,
42 | primaryAPY: {name: 'Curve', APY: curveCropAPY},
43 | secondaryAPYs: additionalCropAPYs //{cropAPY, cropToken, secondaryField}
44 | }
45 | } else {
46 | return curveCropAPY;
47 | }
48 | }
49 |
50 | export default getCurveFarmingAPY;
--------------------------------------------------------------------------------
/client/src/helpers/detailsChartHelpers/detailsPieChartHelper.js:
--------------------------------------------------------------------------------
1 | import pieChartColours from '../../data/pieChartColours';
2 |
3 | export default function extractDetailsPieChartValues(data, type) {
4 | const extractedValues = {data:[], labels: [], fill: [], other:[]};
5 | let colourIndex = 0;
6 |
7 | if (type === 'token') {
8 | const {userBalance, lockedBalance, unclaimedBalance} = data;
9 |
10 | if (userBalance) {
11 | extractedValues.data.push([userBalance]);
12 | extractedValues.labels.push(['Available']);
13 | extractedValues.fill.push(pieChartColours[colourIndex]);
14 | }
15 | colourIndex++;
16 |
17 | lockedBalance && lockedBalance.forEach(lockedBalance => {
18 | extractedValues.data.push(Number(lockedBalance.balance.toFixed(2)));
19 | const lockedBalanceLabel = lockedBalance.via ? lockedBalance.via.name + ` (via ${lockedBalance.field.name})` : lockedBalance.field.name;
20 | extractedValues.labels.push(lockedBalanceLabel);
21 | if (colourIndex === pieChartColours.length) colourIndex = 0;
22 | extractedValues.fill.push(pieChartColours[colourIndex]);
23 | colourIndex++;
24 | });
25 | unclaimedBalance && unclaimedBalance.forEach(unclaimedBalance => {
26 | extractedValues.data.push(Number(unclaimedBalance.balance.toFixed(2)));
27 | extractedValues.labels.push('unclaimed from ' + unclaimedBalance.field.name);
28 | if (colourIndex === pieChartColours.length) colourIndex = 0;
29 | extractedValues.fill.push(pieChartColours[colourIndex]);
30 | colourIndex++;
31 | })
32 | }
33 |
34 | if (type === 'farming') {
35 | const {unclaimed, claimed} = data;
36 |
37 | if (unclaimed.totalValue) {
38 | Object.entries(unclaimed).forEach(entry => {
39 | if (entry[0] !== 'totalValue') {
40 | extractedValues.data.push(Number(entry[1].valueUnclaimed.toFixed(2)));
41 | extractedValues.labels.push(`Unclaimed ${entry[0]}`);
42 | extractedValues.other.push(entry[1].amountUnclaimed.toFixed(2) + ' ' + entry[0]);
43 | extractedValues.fill.push(pieChartColours[colourIndex]);
44 | colourIndex++;
45 | }
46 | })
47 | }
48 |
49 | if (claimed.totalValue) {
50 | Object.entries(claimed).forEach(entry => {
51 | if (entry[0] !== 'totalValue') {
52 | extractedValues.data.push(Number(entry[1].valueClaimed.toFixed(2)));
53 | extractedValues.labels.push(`Claimed ${entry[0]}`);
54 | extractedValues.other.push(entry[1].amountClaimed.toFixed(2) + ' ' + entry[0]);
55 | extractedValues.fill.push(pieChartColours[colourIndex]);
56 | colourIndex++;
57 | }
58 | })
59 | }
60 | }
61 |
62 | return extractedValues;
63 | }
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getFieldSeedReserves.js:
--------------------------------------------------------------------------------
1 | import { ethers } from 'ethers';
2 | import provider from './ethProvider';
3 | import helpers from '../../helpers'
4 |
5 | //CHECK: add cache here instead of parent function?
6 | async function getFieldSeedReserves (field, token, tokenContract, cache) {
7 |
8 | //Check in cache if reserves already fetched
9 | const findFieldinCache = cache.filter(fieldWithReserves => fieldWithReserves.fieldName === field.name)[0];
10 | if (findFieldinCache) {
11 | const seedIndex = findFieldinCache.seedReserves.findIndex(seed => seed.tokenName === token.name);
12 | if (seedIndex !== -1) {
13 | return findFieldinCache.seedReserves[seedIndex].fieldReserve;
14 | }
15 | }
16 |
17 | const reserveAddress = helpers.findFieldAddressType(field, 'underlying');
18 | const { addressType, address, abi } = reserveAddress;
19 | const { decimals } = token.contractInterface;
20 | const tokenIndex = token.seedIndex;
21 |
22 | let fieldReserve;
23 |
24 | switch (addressType) {
25 |
26 | case "curveSwap":
27 |
28 | if (!field.fieldContracts.underlyingContract) {
29 | field.fieldContracts.underlyingContract = new ethers.Contract(address, abi, provider);
30 | }
31 |
32 | fieldReserve = await field.fieldContracts.underlyingContract.balances(tokenIndex);
33 | fieldReserve = ethers.utils.formatUnits(fieldReserve, decimals)
34 | break;
35 |
36 | case "curveSNX":
37 |
38 | if (!field.fieldContracts.underlyingContract) {
39 | field.fieldContracts.underlyingContract = new ethers.Contract(address, abi, provider);
40 | }
41 | const fieldDepositContract = field.contractAddresses.find(contractAddress => contractAddress.addressTypes.includes('deposit'));
42 | fieldReserve = await field.fieldContracts.underlyingContract.balanceOf(fieldDepositContract.address);
43 | fieldReserve = ethers.utils.formatUnits(fieldReserve, decimals)
44 | break;
45 |
46 | case "uniswap":
47 | const reserveContract = field.fieldContracts.balanceContract.contract;
48 | const _fieldReserves = await reserveContract.getReserves();
49 | fieldReserve = Number(ethers.utils.formatUnits(_fieldReserves[tokenIndex], decimals));
50 | break;
51 |
52 | default:
53 | fieldReserve = await tokenContract.contract.balanceOf(address);
54 | fieldReserve = Number(ethers.utils.formatUnits(fieldReserve, decimals));
55 | }
56 |
57 | if (findFieldinCache) {
58 | findFieldinCache.seedReserves.push({
59 | tokenName: token.name,
60 | fieldReserve
61 | })
62 | } else {
63 | cache.push({
64 | fieldName: field.name,
65 | seedReserves: [{tokenName: token.name, fieldReserve}]
66 | })
67 | }
68 |
69 | return fieldReserve;
70 | }
71 |
72 | export default getFieldSeedReserves
--------------------------------------------------------------------------------
/client/src/apis/coinGecko/getTokenPrices.js:
--------------------------------------------------------------------------------
1 | import gecko from './currentPrice';
2 | import supportedCurrencies from './supportedCurrencies';
3 |
4 | async function getTokenPrices(userTokens, userFields, trackedTokens) {
5 |
6 | const apiList = [];
7 | const nonBaseTokens = [];
8 | const currencyModel = {};
9 | supportedCurrencies.forEach(currency => currencyModel[currency] = 0);
10 |
11 | userTokens.forEach(token => {
12 | const { name, tokenId, isBase, priceApi } = token;
13 | if (priceApi) apiList.push(priceApi);
14 | else {
15 | if (!isBase) nonBaseTokens.push({name, tokenId});
16 | else throw new Error(`token ${name} is neither base nor has an price api`);
17 | }
18 | })
19 |
20 | //@dev: assumption that all crop tokens are base tokens
21 | userFields.forEach(userField => {
22 | if (userField.cropTokens.length) {
23 | userField.cropTokens.forEach(token => {
24 | if (!apiList.includes(token.priceApi)) apiList.push(token.priceApi)
25 | })
26 | }
27 | })
28 |
29 | const baseTokenPrices = await gecko.manyPrices(apiList.join());
30 |
31 | // change api response to token name, not priceApi for easy look-up in addFieldInvestmentValues
32 | const revertToName = Object.entries(baseTokenPrices).map(token => {
33 | const targetToken = trackedTokens.find(trackedToken => trackedToken.priceApi === token[0]);
34 | token[0] = targetToken.name;
35 | return token;
36 | })
37 | const tokenPrices = Object.fromEntries(revertToName);
38 |
39 | // determine composite price of non-base tokens
40 | nonBaseTokens.forEach(token => {
41 | //NOTE: recursion may be required in edge cases where field's seeds are not base
42 | const parentField = userFields.find(field => field.receiptToken === token.tokenId);
43 | const { totalSupply } = parentField;
44 | const parentSeeds = parentField.seedTokens.map(seedToken => {
45 | const {name, fieldReserve} = seedToken;
46 | return {name, fieldReserve}
47 | });
48 |
49 | const parentSeedValues = parentSeeds.map(seed => {
50 | const seedReserveValues = {};
51 | for (let currency in tokenPrices[seed.name]) {
52 | seedReserveValues[currency] =
53 | tokenPrices[seed.name][currency]
54 | * seed.fieldReserve
55 | }
56 | return seedReserveValues;
57 | });
58 |
59 | const combinedTokenPrices = parentSeedValues.reduce((acc, curr) => {
60 | for (let currencyVal in curr) {
61 | acc[currencyVal] += curr[currencyVal];
62 | }
63 | return acc;
64 | },{...currencyModel})
65 |
66 | for (let currencyVal in combinedTokenPrices) {
67 | combinedTokenPrices[currencyVal] = combinedTokenPrices[currencyVal]/totalSupply;
68 | }
69 |
70 | tokenPrices[token.name] = combinedTokenPrices;
71 | })
72 |
73 | return tokenPrices;
74 |
75 | }
76 |
77 | export default getTokenPrices;
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getROIs/getROIs.js:
--------------------------------------------------------------------------------
1 | import getUserLiquidityHistory from './earningROIs/getUserLiquidityHistory';
2 | import getUserFarmingHistory from './farmingROIs/getUserFarmingHistory';
3 | import helpers from '../../../helpers';
4 |
5 | /**
6 | *
7 | * @param {String} userAccount user's Eth account
8 | * @param {Array} userFields user's earning and farming fields
9 | * @param {Array} trackedFields all tracked fields
10 | * @param {Array} userTokenTransactions all user ERC20 transactions (pulled from Etherscan)
11 | * @param {Array} trackedTokens all tracked tokens
12 | * @return {Array} userFields with added ROI, user transaction history and current value of investment
13 | */
14 | async function getROIs(userAccount, userFields, trackedFields, userTokenTransactions, userNormalTransactions, trackedTokens, userTokens, tokenPrices) {
15 |
16 | const fieldsWithROI = [...userFields];
17 |
18 | for (let field of fieldsWithROI) {
19 |
20 | let currInvestmentValue = 0;
21 | if (field.unstakedUserInvestmentValue) {
22 | currInvestmentValue += field.unstakedUserInvestmentValue;
23 | }
24 | if (field.stakedBalance) {
25 | currInvestmentValue += field.stakedBalance.reduce((acc, curr) => acc + curr.userInvestmentValue, 0);
26 | }
27 |
28 | if (field.isEarning) {
29 |
30 | //TODO: push these 2 lines to the getUserLiquidityHistory function for consistency & readbility
31 | const receiptToken = trackedTokens.find(trackedToken => trackedToken.tokenId === field.receiptToken);
32 | const userReceiptTokenTxs = userTokenTransactions.filter(tx => tx.contractAddress === receiptToken.address.toLowerCase());
33 |
34 | const userLiquidityHistoryPromises = await getUserLiquidityHistory(trackedFields, field, receiptToken, userReceiptTokenTxs, userAccount);
35 | if (userLiquidityHistoryPromises) {
36 | const userLiquidityHistory = await Promise.all(userLiquidityHistoryPromises);
37 | //TODO: rename variable to totalCurrInvValue
38 | field.investmentValue = currInvestmentValue;
39 | field.userTxHistory = userLiquidityHistory;
40 | //@dev: {allTimeROI, absReturnValue, histInvestmentValue}
41 | field.earningROI = helpers.calcEarningROI(currInvestmentValue, userLiquidityHistory);
42 | }
43 | }
44 |
45 | if (field.cropTokens.length) {
46 | //@dev: [{tx, [crop | receipt]Token, [priceApi,] [reward | staking | unstaking]Value, pricePerToken, txDate}]
47 | const userFarmingHistory = await getUserFarmingHistory(field, userTokenTransactions, userNormalTransactions, trackedFields, userAccount);
48 |
49 | field.investmentValue = currInvestmentValue;
50 | field.userFarmingTxHistory = userFarmingHistory;
51 | field.farmingROI = helpers.calcFarmingROI(userTokens, tokenPrices, field)
52 | }
53 | }
54 | return fieldsWithROI;
55 | }
56 |
57 | export default getROIs;
--------------------------------------------------------------------------------
/client/src/components/SummaryBox/SummaryBox.js:
--------------------------------------------------------------------------------
1 | import React, {useRef, useState, useEffect} from 'react';
2 | import './SummaryBox.css';
3 | import DropdownButton from '../DropdownButton/DropdownButton';
4 | import SummaryTable from '../SummaryTable/SummaryTable';
5 | import helpers from '../../helpers';
6 |
7 | export default function SummaryBox({headlines, userValues, headers, tableName, currencyCells, setCurrentDetail, allLoaded}) {
8 | const [valueDisplay, setValueDisplay] = useState(false)
9 | const holdingTable = useRef(null);
10 | const farmingTable = useRef(null);
11 | const earningTable = useRef(null);
12 | const [boxHeadlines, setBoxHeadlines] = useState({formattedHeadlines: [], perfClasses: []});
13 |
14 |
15 | useEffect(() => {
16 | if (userValues.length) {
17 | setValueDisplay(true);
18 | }
19 | }, [userValues])
20 |
21 | useEffect(() => {
22 | setBoxHeadlines(helpers.formatHeadlines(tableName, headlines));
23 | }, [headlines, tableName]);
24 |
25 | function getRef(tableName){
26 | if (tableName === 'holding') return holdingTable;
27 | if (tableName === 'farming') return farmingTable;
28 | if (tableName === 'earning') return earningTable;
29 | }
30 |
31 | return (
32 | <>
33 |
34 |
{tableName.charAt(0).toUpperCase() + tableName.slice(1)}
35 |
36 |
Loading...
37 |
38 |
39 | {valueDisplay ?
40 | <>
41 |
{`${userValues.length} ${tableName === 'holding' ? 'tokens' : 'investments'}`}
42 | {boxHeadlines.formattedHeadlines.map((headline, index) => (
43 | //FIXME: headline should never come back with a NaN or require the includes/replace hack below
44 | {!headline.includes('NaN') ? headline : headline.replace('NaN', '0')}
45 | ))}
46 | >
47 | : You currently have no {tableName === 'holding' ? 'assets' : 'investments'}
48 | }
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | >
58 | )
59 | }
60 |
61 |
--------------------------------------------------------------------------------
/client/src/components/DetailsBarChart/DetailsBarChart.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect, useRef} from 'react';
2 | import './DetailsBarChart.css';
3 | import helpers from '../../helpers';
4 | const Chart = require('chart.js');
5 |
6 | export default function DetailsBarChart({data, type}) {
7 |
8 | const [tableData, setTableData] = useState({data: [], fill:[], labels: [], other: []});
9 | const chartRef = useRef(null);
10 |
11 | useEffect(() => {
12 | setTableData(helpers.extractDetailsBarChartValues(data, type))
13 | }, [data, type]);
14 |
15 | useEffect(() => {
16 | new Chart(chartRef.current, {
17 | type: 'bar',
18 | data: {
19 | datasets: [{
20 | data: tableData.data,
21 | backgroundColor: tableData.fill,
22 | labels: tableData.labels,
23 | other: tableData.other,
24 | }],
25 | labels: tableData.labels
26 | },
27 | options: {
28 | responsive: true,
29 | maintainAspectRatio: false,
30 | layout: {
31 | padding: {
32 | left: 0
33 | }
34 | },
35 | legend: {
36 | display: false,
37 | },
38 | scales: {
39 | xAxes: [{
40 | ticks: {
41 | fontColor: '#FFF0F5CE'
42 | },
43 | gridLines: {
44 | display: false,
45 | },
46 | }],
47 | yAxes: [{
48 | ticks: {
49 | callback: value => value >= 0 ? '$' + value : '-$' + Math.abs(value),
50 | fontColor: '#FFF0F5CE'
51 | },
52 | gridLines: {
53 | color: '#FFF0F511',
54 | zeroLineColor: '#FFFAFA66',
55 | },
56 | }]
57 | },
58 | tooltips: {
59 | yPadding: 10,
60 | xPadding: 10,
61 | titleSpacing: 100,
62 | bodySpacing: 5,
63 | callbacks: {
64 | label: function(tooltipItem, data) {
65 | if (type === 'earningAndFarming') {
66 | return helpers.chartCallbacks.label.earningAndFarming(tooltipItem, data);
67 | } else {
68 | return Chart.defaults.doughnut.tooltips.callbacks.label(tooltipItem, data);
69 | }
70 | },
71 | title: function(tooltipItem, data) {
72 | if (type === 'earningAndFarming') {
73 | return helpers.chartCallbacks.title.earningAndFarming(tooltipItem, data);
74 | }
75 | },
76 | beforeBody: function(tooltipItem, data) {
77 | if (type === 'earningAndFarming') {
78 | return 'from ' + helpers.chartCallbacks.beforeBody.earningAndFarming(tooltipItem, data);
79 | }
80 | },
81 | }
82 | }
83 | }
84 | });
85 | }, [tableData]); // eslint-disable-line react-hooks/exhaustive-deps
86 |
87 | return (
88 |
89 | )
90 | }
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
2 |
3 | ## Available Scripts
4 |
5 | In the project directory, you can run:
6 |
7 | ### `npm start`
8 |
9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
11 |
12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console.
14 |
15 | ### `npm test`
16 |
17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
19 |
20 | ### `npm run build`
21 |
22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance.
24 |
25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed!
27 |
28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
29 |
30 | ### `npm run eject`
31 |
32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
33 |
34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
35 |
36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
37 |
38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
39 |
40 | ## Learn More
41 |
42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
43 |
44 | To learn React, check out the [React documentation](https://reactjs.org/).
45 |
46 | ### Code Splitting
47 |
48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
49 |
50 | ### Analyzing the Bundle Size
51 |
52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
53 |
54 | ### Making a Progressive Web App
55 |
56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
57 |
58 | ### Advanced Configuration
59 |
60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
61 |
62 | ### Deployment
63 |
64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
65 |
66 | ### `npm run build` fails to minify
67 |
68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
69 |
--------------------------------------------------------------------------------
/server/models/fields.js:
--------------------------------------------------------------------------------
1 | const prisma = require('./db');
2 | const path = require ('path');
3 |
4 | async function getFields() {
5 | try {
6 | const fields = await prisma.field.findMany({
7 | include: {
8 | protocol: {
9 | select: {
10 | name: true
11 | }
12 | },
13 | seedTokens: {
14 | select: {
15 | tokenId: true,
16 | seedIndex: true
17 | }
18 | },
19 | cropTokens: {
20 | select: {
21 | tokenId: true,
22 | unclaimedBalanceMethod: true
23 | }
24 | },
25 | contractAddresses: {
26 | select: {
27 | addressTypes: true,
28 | address: true,
29 | contractInterface: true
30 | }
31 | },
32 | secondaryFields: {
33 | select: {
34 | secondaryField: {
35 | select: {
36 | name: true,
37 | contractAddresses: {
38 | select: {
39 | addressTypes: true,
40 | address: true,
41 | contractInterface: true,
42 | }
43 | },
44 | cropTokens: {
45 | select: {
46 | fieldId: true,
47 | unclaimedBalanceMethod: true,
48 | token: {
49 | select: {
50 | tokenId: true,
51 | name: true,
52 | contractInterface: true,
53 | }
54 | }
55 | }
56 | },
57 | seedTokens: {
58 | select: {
59 | token: {
60 | select: {
61 | name: true
62 | }
63 | }
64 | }
65 | }
66 | }
67 | }
68 | }
69 | }
70 | }
71 | });
72 | return fields;
73 | } catch (err) {
74 | console.error(`Error at ${path.basename(__dirname)}/${path.basename(__filename)} ${err}`);
75 | }
76 | finally {
77 | (async () => {await prisma.$disconnect()})();
78 | }
79 | }
80 |
81 | async function getFieldWithReceiptToken(receiptToken) {
82 | try {
83 | const field = await prisma.field.findOne({
84 | where: {
85 | receiptToken: receiptToken,
86 | },
87 | include: {
88 | fieldSeed: {
89 | select: {
90 | tokenId: true
91 | }
92 | },
93 | fieldCrop: {
94 | select: {
95 | tokenId: true
96 | }
97 | }
98 | }
99 | })
100 | return field;
101 | } catch (err) {
102 | console.error(`Error at ${path.basename(__dirname)}/${path.basename(__filename)} ${err}`);
103 | } finally {
104 | (async () => {await prisma.$disconnect()})();
105 | }
106 | }
107 |
108 | module.exports = {
109 | getFields,
110 | getFieldWithReceiptToken
111 | }
--------------------------------------------------------------------------------
/client/src/helpers/myAssetsHelpers/fieldHelpers.js:
--------------------------------------------------------------------------------
1 | function extractSummaryFieldValues (userFields) {
2 | const farmingFields = [];
3 | const earningFields = [];
4 | const totalInvested = {
5 | farmingInv: 0,
6 | earningInv: 0
7 | };
8 | const totalROI = {
9 | farmingROI: 0,
10 | earningROI: 0
11 | }
12 | const formatter = new Intl.NumberFormat("en-US", {
13 | style: 'percent',
14 | minimumFractionDigits: 2
15 | });
16 |
17 | userFields.forEach(field => {
18 |
19 | const { stakedPercent } = combineFieldBalances(field);
20 | //CHECK: quid using investmentValue and allTimeROI when a field has both farming and earning returns
21 | const { name, cropTokens, isEarning, investmentValue, earningROI, farmingROI } = field;
22 |
23 | if (cropTokens.length) {
24 | let farming = '';
25 | cropTokens && cropTokens.forEach(token => farming += `${token.name}, `);
26 | farming = farming.slice(0, -2);
27 |
28 | totalROI.farmingROI += farmingROI.allTimeROI * investmentValue;
29 | totalInvested.farmingInv += investmentValue;
30 |
31 | const APY = field.farmingAPY?.combinedAPY ? formatter.format(field.farmingAPY.combinedAPY) : formatter.format(field.farmingAPY);
32 | const ROI = formatter.format(farmingROI.allTimeROI);
33 | const invested = Number(investmentValue?.toFixed(2)).toLocaleString();
34 |
35 | farmingFields.push([name, invested, farming, ROI, APY])
36 | }
37 |
38 | if (isEarning) {
39 | //FIXME: ROI weight should be based on the historic investment value
40 | totalROI.earningROI += earningROI.allTimeROI * investmentValue;
41 | totalInvested.earningInv += investmentValue;
42 |
43 | const APY = formatter.format(field.earningAPY);
44 | const ROI = formatter.format(earningROI.allTimeROI);
45 | const invested = Number(investmentValue?.toFixed(2)).toLocaleString();
46 |
47 | earningFields.push([name, invested, stakedPercent, ROI, APY]);
48 | }
49 | })
50 |
51 | if (totalROI.farmingROI) {
52 | totalROI.farmingROI = totalROI.farmingROI / totalInvested.farmingInv;
53 | }
54 |
55 | if (totalROI.earningROI) {
56 | totalROI.earningROI = totalROI.earningROI / totalInvested.earningInv;
57 | }
58 |
59 | return {farmingFields, earningFields, totalInvested, totalROI}
60 | }
61 |
62 | function combineFieldBalances(field){
63 |
64 | let stakedBalance = 0;
65 | let combinedBalance = 0;
66 | let stakedPercent = 0;
67 | const formatter = new Intl.NumberFormat("en-US", {style: 'percent'});
68 |
69 | if (field.stakedBalance) {
70 | stakedBalance = field.stakedBalance.reduce((acc, curr) => acc + curr.balance, 0);
71 | }
72 |
73 | if (field.userBalance) {
74 | combinedBalance = field.userBalance + stakedBalance;
75 | stakedPercent = formatter.format(stakedBalance / combinedBalance);
76 | } else {
77 | combinedBalance = stakedBalance;
78 | stakedPercent = formatter.format(1);
79 | }
80 |
81 | return {
82 | combinedBalance: combinedBalance.toFixed(2),
83 | stakedPercent
84 | };
85 | }
86 |
87 | export {
88 | extractSummaryFieldValues
89 | }
--------------------------------------------------------------------------------
/client/src/components/DetailsTable/DetailsTable.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './DetailsTable.css';
3 | import externalLink from '../../assets/icons/external-link.svg';
4 | import helpers from '../../helpers';
5 |
6 | export default function DetailsTable({txHistory, name}) {
7 | const styleHeaders = ['date', 'action', 'value', 'balance', 'balance-value'];
8 | const columnHeaders = ['Date', 'Action', 'Value', 'Hist. balance', 'Balance value'];
9 | let balance = 0;
10 |
11 | function createCellValues(tempValues) {
12 | const cellValues = [...tempValues];
13 | let balMod = '';
14 |
15 | //set balance amount and modifier
16 | if (tempValues[3] === 'plus') {
17 | balance += tempValues[2];
18 | balMod = `➚ ${Number(tempValues[2].toFixed()).toLocaleString()}`;
19 | } else if (tempValues[3] === 'minus') {
20 | balance -= tempValues[2];
21 | balMod = `➘ ${tempValues[2].toFixed()}`;
22 | } else {
23 | balMod = '↔';
24 | }
25 | cellValues[3] = Number(balance.toFixed(2)).toLocaleString() + ` (${balMod})`;
26 |
27 | // @dev: pricePerReceiptToken is only used when there is a reward claim (txValue based on ppt, balance value based on pprt)
28 | const {pricePerToken, pricePerReceiptToken} = cellValues[4];
29 |
30 | cellValues[2] = Number((cellValues[2] * pricePerToken).toFixed(2)).toLocaleString(); //tx value
31 |
32 | //set hist. balance value
33 | if (pricePerReceiptToken) {
34 | cellValues[4] = Number((pricePerReceiptToken * balance).toFixed(2)).toLocaleString();
35 | } else {
36 | cellValues[4] = Number((pricePerToken * balance).toFixed(2)).toLocaleString();
37 | }
38 |
39 | return cellValues;
40 | }
41 |
42 | //TODO: add CSS hover-over explanations with ? icon
43 | return (
44 |
45 |
46 | {columnHeaders.map((header) => {
47 | return (
48 |
49 |
{header}
50 |
51 | )
52 | })}
53 |
54 |
55 |
56 | {txHistory.map((tx, txIndex) => {
57 | const tempCellValues = helpers.extractTempFieldDetailsCells(tx, balance);
58 | // @dev: returns [date, action, amount, effect-on-balance, {pricePerToken, pricePerReceiptToken}]
59 | const cellValues = createCellValues(tempCellValues)
60 | return (
61 |
62 | {cellValues.map((value, index) => {
63 | return (
64 |
67 | )
68 | })}
69 |
74 |
75 | )
76 | })}
77 |
78 |
79 | )}
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getROIs/farmingROIs/getUserFarmingHistory.js:
--------------------------------------------------------------------------------
1 | import { getOneCurveHistReceiptPrice } from './getCurveFarmingPriceHistory';
2 | import getOneUniswapHistReceiptPrice from './getUniswapFarmingPriceHistory';
3 | import getHistoricalPrice from '../../../coinGecko/getHistoricalPrice';
4 | import helpers from '../../../../helpers';
5 |
6 | /**
7 | *
8 | * @param {Object} field - currently analysed farming field
9 | * @param {Array} userTokenTransactions - all user ERC20 transactions
10 | * @param {Array} trackedFields - all fields tracked by SimpleFi
11 | * @return {Array} - an array of objects containing :{tx, [crop | receipt]Token, [priceApi,] [reward | staking | unstaking]Amount, pricePerToken}
12 | * @dev - in sortFarmingTxs():
13 | * Presence of a cropToken means that the user claimed a reward and corresponds to the presence of a rewardAmount property)
14 | * receiptToken is present in all tx types ((un)staking and reward claims)
15 | * - in getHistoricalPrice(): assumes all crop tokens are base (and have a coinGecko price api code)
16 | */
17 | async function getUserFarmingHistory(field, userTokenTransactions, userNormalTransactions, trackedFields, userAccount) {
18 | const timeFormatter = new Intl.DateTimeFormat('en-GB');
19 |
20 | //@dev: farmingTxs = [{tx, receiptToken, [cropToken,] [priceApi,] [reward | staking | unstaking]Amount}]
21 | const farmingTxs = helpers.sortFarmingTxs(field, userTokenTransactions, userNormalTransactions);
22 |
23 | for (let tx of farmingTxs) {
24 |
25 | // add hist. price of the reward claim tx
26 | if (tx.cropToken) {
27 | const geckoDateFormat = timeFormatter.format(new Date(Number(tx.tx.timeStamp) * 1000)).replace(/\//gi, '-');
28 | const histTokenPrice = await getHistoricalPrice (tx.priceApi, geckoDateFormat);
29 | tx.txDate = new Date(Number(tx.tx.timeStamp) * 1000);
30 | tx.pricePerToken = histTokenPrice;
31 | }
32 | //CHECK: does this work properly with pure SNX staking?
33 | /* add historical prices of (un)staking transactions based on field issuing the receipt token used as this farming field's seed
34 | and price of the receipt token in case of a rewards claim for use in the Farming Field Details page
35 | */
36 | let receiptTokenPriceAndDate;
37 | switch (field.seedTokens[0].protocol.name) {
38 | case 'Curve':
39 | const {receiptToken} = tx;
40 | const txTimestamp = tx.tx.timeStamp;
41 | receiptTokenPriceAndDate = await getOneCurveHistReceiptPrice(receiptToken, txTimestamp, trackedFields);
42 | break;
43 |
44 | case 'Uniswap':
45 | const txBlockNumber = tx.tx.blockNumber;
46 | receiptTokenPriceAndDate = await getOneUniswapHistReceiptPrice(txBlockNumber, userAccount);
47 | break;
48 |
49 | default:
50 | }
51 |
52 | if (tx.cropToken) {
53 | tx.pricePerReceiptToken = receiptTokenPriceAndDate.pricePerToken;
54 | } else {
55 | tx.pricePerToken = receiptTokenPriceAndDate.pricePerToken;
56 | tx.txDate = receiptTokenPriceAndDate.txDate;
57 | }
58 |
59 | }
60 |
61 | //@dev: [{tx, receiptToken, [cropToken,] txDate, [reward | staking | unstaking]Amount, pricePerToken, [pricePerReceiptToken,] [priceApi]}]
62 | return farmingTxs;
63 | }
64 |
65 | export default getUserFarmingHistory;
--------------------------------------------------------------------------------
/client/src/apis/ethereum/getROIs/farmingROIs/getCurveFarmingPriceHistory.js:
--------------------------------------------------------------------------------
1 | import getHistoricalPrice from '../../../coinGecko/getHistoricalPrice';
2 | import { getOneCurvePoolRawData } from '../../protocolQueries';
3 |
4 | /**
5 | *
6 | * @param {Object} token - the target token for which the historical price is required
7 | * @param {Timestamp} timestamp - historical date on which the hist. price is sought
8 | * @param {Array} trackedFields - all SimpleFi tracked fields
9 | * @dev - this getter fetches the full history of daily balances and supplies from the Curve API
10 | *
11 | * @return {Object} - returns a historical price per token and a formatted txDate (important for display in field details tx table)
12 | */
13 |
14 | async function getOneCurveHistReceiptPrice(token, timestamp, trackedFields) {
15 | //@dev: assumes that Curve staking/farming fields only have one seed token
16 | const targetEarnField = trackedFields.find(trackedField => trackedField.receiptToken === token.tokenId);
17 | const historicalCurveStats = await getOneCurvePoolRawData(targetEarnField.name);
18 | const timeFormatter = new Intl.DateTimeFormat('en-GB');
19 | const txDate = new Date(Number(timestamp) * 1000);
20 | const compDate = timeFormatter.format(txDate);
21 | const historicalStat = historicalCurveStats.find(day => compDate === timeFormatter.format(new Date(Number(day.timestamp) * 1000)));
22 | const geckoDateformat = compDate.replace(/\//gi, '-');
23 |
24 | let fieldHistReserveValue = 0;
25 |
26 | for (let seed of targetEarnField.seedTokens) {
27 | const histSeedValue = await getHistoricalPrice(seed.priceApi, geckoDateformat);
28 | //CHECK: handle error in case no seed index (some seeds only added later to contract and are not present in old raw stats)
29 | const decimaledReserve = historicalStat.balances[seed.seedIndex]/Number(`1e${seed.tokenContract.decimals}`);
30 | fieldHistReserveValue += histSeedValue * decimaledReserve;
31 | }
32 |
33 | const pricePerToken = fieldHistReserveValue / (historicalStat.supply / Number(`1e${token.tokenContract.decimals}`));
34 | return {pricePerToken, txDate};
35 | }
36 |
37 | //NOTE: this function is not currently in use
38 | async function getCurveHistReceiptPrices (field, receiptToken, userReceiptTokenTxs) {
39 | const historicalCurveStats = await getOneCurvePoolRawData(field.name);
40 | const timeFormatter = new Intl.DateTimeFormat('en-GB');
41 |
42 | const txHistoryWithPrices = userReceiptTokenTxs.map(async tx => {
43 | const txDate = new Date(Number(tx.timeStamp) * 1000);
44 | const compDate = timeFormatter.format(txDate);
45 | const historicalStat = historicalCurveStats.find(day => compDate === timeFormatter.format(new Date(Number(day.timestamp) * 1000)));
46 |
47 | const geckoDateformat = compDate.replace(/\//gi, '-')
48 | let fieldHistReserveValue = 0;
49 |
50 | for (let seed of field.seedTokens) {
51 | const histSeedValue = await getHistoricalPrice(seed.priceApi, geckoDateformat);
52 | const decimaledReserve = historicalStat.balances[seed.seedIndex]/Number(`1e${seed.tokenContract.decimals}`);
53 | fieldHistReserveValue += histSeedValue * decimaledReserve;
54 | }
55 | //TODO: check impact of split admin fees and use of virtual price
56 | const pricePerToken = fieldHistReserveValue / (historicalStat.supply / Number(`1e${receiptToken.tokenContract.decimals}`));
57 | return {tx, receiptToken, pricePerToken, txDate}
58 | })
59 |
60 | return txHistoryWithPrices;
61 | }
62 |
63 | export {
64 | getOneCurveHistReceiptPrice,
65 | getCurveHistReceiptPrices
66 | }
--------------------------------------------------------------------------------
/client/src/helpers/ethHelpers/ethROIHelpers/sortFarmingTxs.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * @param {Object} field currently analysed farming field
4 | * @param {Array} userTokenTransactions all user ERC20 transactions
5 | * @param {Array} userNormalTransactions all user "normal" transactions
6 | * @return {Array} - user farming transactions sorted by type: staking, unstaking or claim
7 | * type is deduced from the [staking | unstaking | reward]Amount property
8 | * @dev - note that the receiptToken property is added to all transactions, even reward claims
9 | * this is because for reward claims it will be used to get an accurate read of the historical
10 | * balance in the Farming details page transaction table
11 | */
12 |
13 | function sortFarmingTxs(field, userTokenTransactions, userNormalTransactions) {
14 | const rewardDepositContract = field.contractAddresses.find(contractAddress => contractAddress.addressTypes.includes('deposit'));
15 | const rewardWithdrawalContract = field.contractAddresses.find(contractAddress => contractAddress.addressTypes.includes('withdraw'));
16 |
17 | const cropTokenAddresses = {};
18 | field.cropTokens.forEach(cropToken => {
19 | cropTokenAddresses[cropToken.address.toLowerCase()] = cropToken;
20 | });
21 |
22 | const sortedTxs = userTokenTransactions.reduce((acc, tx) => {
23 |
24 | const receiptToken = field.seedTokens[0];
25 |
26 | //identify rewards claimed
27 | if (cropTokenAddresses[tx.contractAddress]) {
28 |
29 | //Check if reward address is in input method rather than from address
30 | let addressInMethod = false;
31 | if (tx.from === '0x0000000000000000000000000000000000000000') {
32 | const referenceTx = userNormalTransactions.find(normalTx => normalTx.hash === tx.hash);
33 | if (referenceTx) {
34 | const methodInput = '0x' + referenceTx.input.slice(-40);
35 | if (methodInput === rewardWithdrawalContract.address.toLowerCase()) addressInMethod = true;
36 | }
37 | }
38 |
39 | //ASK: should this rather be named unclaimedReward contract?
40 | if (tx.from === rewardWithdrawalContract.address.toLowerCase() || addressInMethod) {
41 | const cropToken = cropTokenAddresses[tx.contractAddress];
42 | //@dev: assumes all crop tokens are base tokens in DB
43 | const priceApi = cropToken.priceApi;
44 | const rewardAmount = tx.value / Number(`1e${cropToken.contractInterface.decimals}`);
45 | return [...acc, {tx, cropToken, priceApi, rewardAmount, receiptToken}]
46 | } else {
47 | return acc;
48 | }
49 |
50 | //@dev: assumes only one seed token per staking/farming field
51 | } else if (tx.contractAddress === field.seedTokens[0].address.toLowerCase()) {
52 | //identify staking tx
53 | //@dev: assumes the correct deposit method was used
54 | if (tx.to === rewardDepositContract.address.toLowerCase()) {
55 | const stakingAmount = tx.value / Number(`1e${receiptToken.contractInterface.decimals}`);
56 | return [...acc, {tx, receiptToken, stakingAmount}];
57 | //identify unstaking tx
58 | } else if (tx.from === rewardWithdrawalContract.address.toLowerCase()) {
59 | const unstakingAmount = tx.value / Number(`1e${receiptToken.contractInterface.decimals}`);
60 | return [...acc, {tx, receiptToken, unstakingAmount}];
61 | } else {
62 | return acc;
63 | }
64 |
65 | } else {
66 | return acc;
67 | }
68 | }, []);
69 |
70 | return sortedTxs;
71 | }
72 |
73 | export default sortFarmingTxs;
--------------------------------------------------------------------------------
/client/src/apis/ethereum/rewinder.js:
--------------------------------------------------------------------------------
1 | import helpers from '../../helpers';
2 | import { getTotalFieldSupply, getFieldSeedReserves } from './';
3 |
4 | /**
5 | *
6 | * @param {Array} userFields - all fields the user is currently invested in at a depth of 0 (investment not restaked elsewhere)
7 | * @param {Array} trackedTokens - all tokens tracked by SimpleFi
8 | * @param {Array} trackedFields - all earning and farming fields tracked by SimpleFi
9 | */
10 | async function rewinder (userFields, trackedTokens, trackedFields) {
11 |
12 | const userTokenBalances = [];
13 | const userFeederFieldBalances = [];
14 | const totalFieldSupplyCache = []; // { fieldName, totalFieldSupply }
15 | const fieldSeedReserveCache = []; // { fieldName, seedReserves: [{tokenName, fieldReserve}] }
16 |
17 | for (const mainField of userFields) {
18 |
19 | const { contract, decimals } = mainField.fieldContracts.balanceContract;
20 | /*
21 | @dev: total supply indicates either 1) how many receipt tokens have been minted by the field
22 | or 2) how many input tokens the field holds (in cases where it issues no receipts)
23 | */
24 | const totalMainFieldSupply = await getTotalFieldSupply(mainField.name, contract, decimals, totalFieldSupplyCache);
25 | const userShareOfMainField = mainField.userBalance / totalMainFieldSupply;
26 |
27 | //@dev: will extract the balance of underlying seed tokens owned by the user
28 | for (const token of mainField.seedTokens) {
29 | await tokenBalanceExtractor(token, mainField, userShareOfMainField)
30 | }
31 | }
32 |
33 | const fieldBalances = helpers.combineFieldSuppliesAndReserves(totalFieldSupplyCache, fieldSeedReserveCache);
34 |
35 |
36 | return {
37 | userTokenBalances,
38 | userFeederFieldBalances,
39 | fieldBalances
40 | };
41 |
42 | //@dev via indicates the parent field via which the user has a token balance from a feederfield
43 | async function tokenBalanceExtractor (token, field, share, via) {
44 | const { tokenId, isBase, tokenContract } = token;
45 |
46 | //@dev: field seed reserves are the number of underlying tokens held by the field
47 | let fieldSeedReserve = await getFieldSeedReserves(field, token, tokenContract, fieldSeedReserveCache);
48 |
49 | // if isBase or !isBase
50 | const userTokenBalance = fieldSeedReserve * share;
51 | const balanceObj = {token, userTokenBalance, field};
52 | if (via) balanceObj.via = via;
53 | userTokenBalances.push(balanceObj);
54 |
55 | if (!isBase) {
56 | let feederField = trackedFields.find(field => field.receiptToken === tokenId);
57 | const parentField = field;
58 |
59 | //TODO: stop this from changing tracked Fields as well as user fields
60 | //TODO: avoid populating this multiple times (once in App.js)
61 | [feederField] = helpers.populateFieldTokensFromCache([feederField], trackedTokens);
62 |
63 | const { contract, decimals } = feederField.fieldContracts.balanceContract;
64 | const totalFeederSupply = await getTotalFieldSupply(feederField.name, contract, decimals, totalFieldSupplyCache);
65 | const userFieldBalance = fieldSeedReserve * share;
66 | const userFeederShare = userFieldBalance / totalFeederSupply;
67 |
68 | //rewoundFieldBalances will contain any field with a receipt token that was fed into a field the user has staked in
69 | userFeederFieldBalances.push({feederField, userFieldBalance, parentField});
70 |
71 | for (const token of feederField.seedTokens) {
72 | await tokenBalanceExtractor(token, feederField, userFeederShare, parentField)
73 | }
74 | }
75 | }
76 | }
77 |
78 | export default rewinder;
79 |
80 |
--------------------------------------------------------------------------------
/client/src/components/LoadingModal/LoadingModal.css:
--------------------------------------------------------------------------------
1 | @import '../../CSS/variables.css';
2 |
3 | .loading-modal {
4 | display: flex;
5 | background-color: rgba(0,0,0,0.5);
6 | z-index: 1;
7 | height: 100%;
8 | width: 100%;
9 | position: fixed;
10 | top: 0;
11 | left: 0;
12 | overflow: auto;
13 | padding-top: 160px;
14 | }
15 |
16 | .loading-modal-content {
17 | position: relative;
18 | background-color: var(--transp-offwhite);
19 | color: var(--dark-grey-font);
20 | display: flex;
21 | flex-direction: column;
22 | box-shadow: 5px 5px 10px 0px var(--periwinkle);
23 | width: 70%;
24 | max-width: 600px;
25 | height: 40%;
26 | margin: auto;
27 | border-radius: 20px;
28 | }
29 |
30 | .loading-modal-text {
31 | padding: 30px 0 0 50px;
32 | }
33 |
34 | .loading-modal-content h2 {
35 | color: var(--darkest-grey-font);
36 | font-size: 1.2em;
37 | margin-bottom: 10px;
38 | }
39 |
40 | .loading-actions {
41 | display: flex;
42 | flex-direction: column;
43 | margin-top: 20px;
44 | }
45 |
46 | .loading-actions p {
47 | color: var(--darkest-grey-font);
48 | line-height: 1.8em;
49 | }
50 |
51 | .loading-actions .loading-modal-tick {
52 | color: var(--success-green);
53 | }
54 |
55 | .loading-animation{
56 | position: absolute;
57 | bottom: 0;
58 | margin-bottom: 60px;
59 | margin-left: auto;
60 | margin-right: auto;
61 | left: 0;
62 | right: 0;
63 | }
64 |
65 | .dot-bricks {
66 | position: relative;
67 | margin: auto;
68 | top: 8px;
69 | left: -9999px;
70 | width: 10px;
71 | height: 10px;
72 | border-radius: 5px;
73 | background-color: var(--modal-dot);
74 | color: var(--modal-dot);
75 | box-shadow: 9991px -16px 0 0 var(--modal-dot), 9991px 0 0 0 var(--modal-dot), 10007px 0 0 0 var(--modal-dot);
76 | animation: dotBricks 2s infinite ease;
77 | }
78 |
79 | @keyframes dotBricks {
80 | 0% {
81 | box-shadow: 9991px -16px 0 0 var(--modal-dot), 9991px 0 0 0 var(--modal-dot), 10007px 0 0 0 var(--modal-dot);
82 | }
83 | 8.333% {
84 | box-shadow: 10007px -16px 0 0 var(--modal-dot), 9991px 0 0 0 var(--modal-dot), 10007px 0 0 0 var(--modal-dot);
85 | }
86 | 16.667% {
87 | box-shadow: 10007px -16px 0 0 var(--modal-dot), 9991px -16px 0 0 var(--modal-dot), 10007px 0 0 0 var(--modal-dot);
88 | }
89 | 25% {
90 | box-shadow: 10007px -16px 0 0 var(--modal-dot), 9991px -16px 0 0 var(--modal-dot), 9991px 0 0 0 var(--modal-dot);
91 | }
92 | 33.333% {
93 | box-shadow: 10007px 0 0 0 var(--modal-dot), 9991px -16px 0 0 var(--modal-dot), 9991px 0 0 0 var(--modal-dot);
94 | }
95 | 41.667% {
96 | box-shadow: 10007px 0 0 0 var(--modal-dot), 10007px -16px 0 0 var(--modal-dot), 9991px 0 0 0 var(--modal-dot);
97 | }
98 | 50% {
99 | box-shadow: 10007px 0 0 0 var(--modal-dot), 10007px -16px 0 0 var(--modal-dot), 9991px -16px 0 0 var(--modal-dot);
100 | }
101 | 58.333% {
102 | box-shadow: 9991px 0 0 0 var(--modal-dot), 10007px -16px 0 0 var(--modal-dot), 9991px -16px 0 0 var(--modal-dot);
103 | }
104 | 66.666% {
105 | box-shadow: 9991px 0 0 0 var(--modal-dot), 10007px 0 0 0 var(--modal-dot), 9991px -16px 0 0 var(--modal-dot);
106 | }
107 | 75% {
108 | box-shadow: 9991px 0 0 0 var(--modal-dot), 10007px 0 0 0 var(--modal-dot), 10007px -16px 0 0 var(--modal-dot);
109 | }
110 | 83.333% {
111 | box-shadow: 9991px -16px 0 0 var(--modal-dot), 10007px 0 0 0 var(--modal-dot), 10007px -16px 0 0 var(--modal-dot);
112 | }
113 | 91.667% {
114 | box-shadow: 9991px -16px 0 0 var(--modal-dot), 9991px 0 0 0 var(--modal-dot), 10007px -16px 0 0 var(--modal-dot);
115 | }
116 | 100% {
117 | box-shadow: 9991px -16px 0 0 var(--modal-dot), 9991px 0 0 0 var(--modal-dot), 10007px 0 0 0 var(--modal-dot);
118 | }
119 | }
120 |
121 |
--------------------------------------------------------------------------------
/client/src/containers/MyAssets/MyAssets.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import './MyAssets.css';
3 | import OverviewCard from '../../components/OverViewCard/OverviewCard';
4 | import SummaryBox from '../../components/SummaryBox/SummaryBox';
5 | import helpers from '../../helpers/index';
6 | import { holdingHeaders, holdingCurrencyCells, farmingHeaders, farmingCurrencyCells, earningHeaders, earningCurrencyCells } from '../../data/summaryHeaders';
7 |
8 | export default function MyAssets ({userTokens, userFields, userTokenPrices, setCurrentDetail, allLoadedFlag, setSplash}) {
9 | const [holdingHeadlines, setHoldingHeadlines] = useState({totalInvested: 0, totalUnclaimed: 0, totalValue: 0});
10 | const [farmingHeadlines, setFarmingHeadlines] = useState(['Loading', 'Loading']);
11 | const [earningHeadlines, setEarningHeadlines] = useState(['Loading', 'Loading']);
12 | const [holdingValues, setHoldingValues] = useState({ baseTokens:[], receiptTokens:[] });
13 | const [farmingValues, setFarmingValues] = useState([]);
14 | const [earningValues, setEarningValues] = useState([]);
15 | const [totalROI, setTotalROI] = useState({farmingROI: 0, earningROI: 0});
16 |
17 | useEffect(() => {
18 | window.scrollTo(0, 0);
19 | },[])
20 |
21 | // combine available and locked token balances and add prices from coinGecko
22 | // separate farming and earning fields
23 | useEffect(() => {
24 | if(allLoadedFlag) {
25 | const {summaryTableValues, overviewValues} = helpers.extractSummaryHoldingValues(userTokens, userTokenPrices);
26 | setHoldingValues(summaryTableValues);
27 | setHoldingHeadlines(overviewValues);
28 |
29 | const {farmingFields, earningFields, totalInvested, totalROI} = helpers.extractSummaryFieldValues(userFields);
30 | setFarmingHeadlines({investment: totalInvested.farmingInv, ROI: totalROI.farmingROI});
31 | setEarningHeadlines({investment: totalInvested.earningInv, ROI: totalROI.earningROI});
32 |
33 | setFarmingValues(farmingFields);
34 | setEarningValues(earningFields);
35 | setTotalROI(totalROI);
36 | } else {
37 | setSplash(true)
38 | }
39 | // eslint-disable-next-line react-hooks/exhaustive-deps
40 | }, [allLoadedFlag])
41 |
42 | return (
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
Account overview
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | )
70 | }
--------------------------------------------------------------------------------
/client/src/assets/logos/simplefi-logotype.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/client/src/helpers/appHelpers/addLockedAndStakedBalances.js:
--------------------------------------------------------------------------------
1 | function addUnclaimedBalances(unclaimedBalances, userTokens, trackedTokens) {
2 | const updatedUserTokens = [...userTokens];
3 |
4 | unclaimedBalances.forEach(unclaimedToken => {
5 | //identify if user already has a balance for curr token
6 | const existingUserToken = updatedUserTokens.find(userToken => userToken.tokenId === unclaimedToken.tokenId);
7 | //if so, add rewound token balance to the token's locked balance
8 | if (existingUserToken && existingUserToken.unclaimedBalance) {
9 | existingUserToken.unclaimedBalance.push({balance: unclaimedToken.unclaimedBalance, field: unclaimedToken.field});
10 | }
11 | else if (existingUserToken) existingUserToken.unclaimedBalance = [{balance: unclaimedToken.unclaimedBalance, field: unclaimedToken.field}];
12 | //otherwise: create a new user Token
13 | else {
14 | //CHECK: check this is necessary
15 | const newUserToken = trackedTokens.find(trackedToken => trackedToken.tokenId === unclaimedToken.tokenId);
16 | newUserToken.unclaimedBalance = [{balance: unclaimedToken.unclaimedBalance, field: unclaimedToken.field}]
17 | updatedUserTokens.push(newUserToken);
18 | }
19 | })
20 | return updatedUserTokens;
21 | }
22 |
23 | function addLockedTokenBalances (rewoundTokens, userTokens) {
24 | const updatedUserTokens = [...userTokens];
25 |
26 | rewoundTokens.forEach(rewoundToken => {
27 | //identify if user already has a balance for curr token
28 | const existingUserToken = updatedUserTokens.find(userToken => userToken.tokenId === rewoundToken.token.tokenId);
29 | //if so, add rewound token balance to the token's locked balance
30 | if (existingUserToken && existingUserToken.lockedBalance) {
31 | const lockedBalanceObj = {balance: rewoundToken.userTokenBalance, field: rewoundToken.field};
32 | if (rewoundToken.via) lockedBalanceObj.via = rewoundToken.via;
33 | existingUserToken.lockedBalance.push(lockedBalanceObj);
34 | }
35 | else if (existingUserToken) {
36 | const lockedBalanceObj = {balance: rewoundToken.userTokenBalance, field: rewoundToken.field};
37 | if (rewoundToken.via) lockedBalanceObj.via = rewoundToken.via;
38 | existingUserToken.lockedBalance = [lockedBalanceObj];
39 | }
40 | //otherwise: create a new user Token
41 | else {
42 | //CHECK: check this is necessary
43 | const newUserToken = JSON.parse(JSON.stringify(rewoundToken.token));
44 | const lockedBalanceObj = {balance: rewoundToken.userTokenBalance, field: rewoundToken.field};
45 | if (rewoundToken.via) lockedBalanceObj.via = rewoundToken.via;
46 | newUserToken.lockedBalance = [lockedBalanceObj]
47 | updatedUserTokens.push(newUserToken);
48 | }
49 | })
50 | return updatedUserTokens;
51 | }
52 |
53 |
54 | function addStakedFieldBalances (rewoundFields, userFields) {
55 | //CHECK: is this necessary?
56 | const updatedUserFields = [...userFields];
57 | rewoundFields.forEach(rewoundField => {
58 | //identify if user already has a balance for curr field
59 | const existingUserField = updatedUserFields.find(userField => userField.fieldId === rewoundField.feederField.fieldId);
60 | //if so, add rewound field balance to the subField balance
61 | if (existingUserField && existingUserField.stakedBalance) {
62 | existingUserField.stakedBalance.push({balance: rewoundField.userFieldBalance, parentField: rewoundField.parentField});
63 | }
64 | else if (existingUserField) existingUserField.stakedBalance = [{balance: rewoundField.userFieldBalance, parentField: rewoundField.parentField}];
65 | //otherwise: create a new user Field
66 | else {
67 | //CHECK: check this is necessary
68 | const newUserField = JSON.parse(JSON.stringify(rewoundField.feederField));
69 | newUserField.stakedBalance = [{balance: rewoundField.userFieldBalance, parentField: rewoundField.parentField}]
70 | updatedUserFields.push(newUserField);
71 | }
72 | })
73 | return updatedUserFields;
74 | }
75 |
76 | export {
77 | addUnclaimedBalances,
78 | addLockedTokenBalances,
79 | addStakedFieldBalances
80 | }
--------------------------------------------------------------------------------
/client/src/assets/icons/external-link.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Created with Fabric.js 4.2.0
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/client/src/helpers/ethHelpers/ethROIHelpers/calcFarmingROI.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {Array} userTokens - all tokens the user currently holds either directly or indirectly (via a field)
3 | * @param {Array} tokenPrices - current price in fiat of user tokens
4 | * @param {Object} field - currently analysed field from which are extracted the user's field transactions
5 | * @dev - the term "value" refers to fiat value. "Amount" refers to actual token amount
6 | * @return {Object} - all data used for presentation in field details page, incl.
7 | * - ROI(%)
8 | * - return value($)
9 | * - avg historical investment value ($)
10 | * - breakdown of (un)claimed tokens
11 | */
12 | function calcFarmingROI (userTokens, tokenPrices, field) {
13 | const {cropTokens, fieldId} = field;
14 | const unclaimed = {};
15 | const claimed = {}; //claimed value is calculated based on token price at that date (no calc of what it's worth today if user held onto it)
16 | //used to record the amount of time an investment was made for
17 | //currentInv.amount not currently used in calcs, but may be at a later date
18 | const currentInv = {value: 0, amount: 0, dateStart: null};
19 | const weightedInvestments =[];
20 |
21 | //userTx shape: [{tx, [crop | receipt]Token, [priceApi,] [reward | staking | unstaking]Amount, pricePerToken}]
22 | field.userFarmingTxHistory.forEach(userTx => {
23 | const { rewardAmount, stakingAmount, unstakingAmount, pricePerToken} = userTx;
24 |
25 | if (rewardAmount) {
26 | if (!claimed[userTx.cropToken.name]) {
27 | claimed[userTx.cropToken.name] = {amountClaimed: 0, valueClaimed: 0};
28 | }
29 | claimed[userTx.cropToken.name].amountClaimed += rewardAmount;
30 | claimed[userTx.cropToken.name].valueClaimed += rewardAmount * pricePerToken;
31 |
32 | } else if (stakingAmount) {
33 | if (currentInv.value) {
34 | const stakedTime = userTx.txDate - currentInv.dateStart;
35 | weightedInvestments.push({amount: currentInv.amount, value: currentInv.value, stakedTime});
36 | }
37 | currentInv.value += stakingAmount * pricePerToken;
38 | currentInv.amount += stakingAmount;
39 | currentInv.dateStart = userTx.txDate;
40 |
41 | } else if (unstakingAmount) {
42 | const stakedTime = userTx.txDate - currentInv.dateStart;
43 | weightedInvestments.push({amount: currentInv.amount, value: currentInv.value, stakedTime});
44 |
45 | //set currentInv value to 0 if it's just dust in dollar terms
46 | if ((currentInv.value - (unstakingAmount * pricePerToken)) < 1) {
47 | currentInv.value = 0;
48 | currentInv.amount = 0;
49 | currentInv.dateStart = null;
50 | } else {
51 | currentInv.value -= unstakingAmount * pricePerToken;
52 | currentInv.amount -= unstakingAmount;
53 | currentInv.dateStart = userTx.txDate;
54 | }
55 | }
56 | })
57 |
58 | //set any current investment as having lasted till today's date
59 | if (currentInv.value) {
60 | const stakedTime = new Date() - currentInv.dateStart;
61 | weightedInvestments.push({amount: currentInv.amount, value: currentInv.value, stakedTime});
62 | }
63 |
64 | //determine avg historical investment value based on relative amount of time each incremental investment was made for
65 | const reducedInvestmentWeights = weightedInvestments.reduce((acc, curr) => {
66 | acc.investments += curr.value * curr.stakedTime;
67 | acc.totalTime += curr.stakedTime;
68 | return acc;
69 | }, {investments: 0, totalTime: 0});
70 | const avgInvestment = reducedInvestmentWeights.investments / reducedInvestmentWeights.totalTime;
71 |
72 | const targetCropTokens = userTokens.filter(userToken => {
73 | return cropTokens.some(cropToken => cropToken.tokenId === userToken.tokenId);
74 | });
75 |
76 | //set aggregate amount of unclaimed tokens and value based on today's token price
77 | targetCropTokens.forEach(token => {
78 | unclaimed[token.name] = token.unclaimedBalance.reduce((acc, curr) => {
79 | if (curr.field.fieldId === fieldId) {
80 | acc.amountUnclaimed += curr.balance;
81 | acc.valueUnclaimed += curr.balance * tokenPrices[token.name].usd;
82 | }
83 | return acc;
84 | }, {amountUnclaimed: 0, valueUnclaimed: 0})
85 | })
86 | claimed.totalValue = Object.values(claimed).reduce((acc, curr) => acc + curr.valueClaimed, 0);
87 | unclaimed.totalValue = Object.values(unclaimed).reduce((acc, curr) => acc + curr.valueUnclaimed, 0);
88 |
89 | const absReturnValue = unclaimed.totalValue + claimed.totalValue;
90 | const allTimeROI = absReturnValue / avgInvestment;
91 |
92 | return {allTimeROI, absReturnValue, unclaimed, claimed, avgInvestment}
93 | }
94 |
95 | export default calcFarmingROI;
--------------------------------------------------------------------------------
/client/src/containers/FarmingFieldDetails/FarmingFieldDetails.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect} from 'react';
2 | import './FarmingFieldDetails.css';
3 | import DetailsPieChart from '../../components/DetailsPieChart/DetailsPieChart';
4 | import DetailsTable from '../../components/DetailsTable/DetailsTable';
5 | import MiniToggle from '../../components/MiniToggle/MiniToggle';
6 |
7 | //TODO: identify joint components with EarningFieldDetails container
8 |
9 | export default function FarmingFieldDetails({name, userFields, history}) {
10 |
11 | const [currentField] = useState(userFields.find(field => field.name === name));
12 | const [underlyingTokens, setUnderlyingTokens] = useState([]);
13 | const [mainAPY, setMainAPY] = useState(0);
14 | const [secondaryFarmingTokens, setSecondaryFarmingTokens] = useState(null);
15 | const [secondaryAPYs, setSecondaryAPYs] = useState(null);
16 | const [lockedValue, setLockedValue] = useState({title: 'Current', value: '$0'});
17 | const [ROIValue, setROIValue] = useState({title: 'Total ROI', value: '0%'});
18 |
19 | //@dev: assumes there is a single seed/staking token
20 | useEffect(() => {
21 | window.scrollTo(0, 0);
22 | if (name) {
23 | setUnderlyingTokens(userFields.find(userField => userField.receiptToken === currentField.seedTokens[0].tokenId).seedTokens);
24 | setMainAPY(currentField.farmingAPY.primaryAPY ? `${(currentField.farmingAPY.primaryAPY.APY * 100).toFixed(2)}% (${currentField.farmingAPY.primaryAPY.name})` : `${(currentField.farmingAPY * 100).toFixed(2)}% (${currentField.cropTokens[0].name})`);
25 | const tempSecondaryFarmingTokens = currentField.farmingAPY.secondaryAPYs ? currentField.farmingAPY.secondaryAPYs : null;
26 | if (tempSecondaryFarmingTokens) {
27 | setSecondaryFarmingTokens(tempSecondaryFarmingTokens);
28 | setSecondaryAPYs(tempSecondaryFarmingTokens.reduce((acc, curr) => `${acc} ${(curr.cropAPY * 100).toFixed(2)}% (${curr.cropToken.name}), `, '').slice(0, -2))
29 | }
30 |
31 | setLockedValue({title: 'Current', value: Number(currentField.investmentValue.toFixed()).toLocaleString()});
32 | setROIValue({title: 'Total ROI', value: `${(currentField.farmingROI.allTimeROI * 100).toFixed(2)}%`})
33 | }
34 | // eslint-disable-next-line react-hooks/exhaustive-deps
35 | }, [currentField]);
36 |
37 | function toggleFarmingLocked(e) {
38 | if (e.target.checked) {
39 | setLockedValue(prev => ({
40 | title: 'Average historic',
41 | value: Number(currentField.farmingROI.avgInvestment.toFixed()).toLocaleString()
42 | }))
43 | } else {
44 | setLockedValue(prev => ({
45 | title: 'Current',
46 | value: Number(currentField.investmentValue.toFixed()).toLocaleString()
47 | }))
48 | }
49 | }
50 |
51 | function toggleFarmingROI(e) {
52 | if (e.target.checked) {
53 | setROIValue(prev => ({
54 | title: 'Total reward value',
55 | value: `$${Number(currentField.farmingROI.absReturnValue.toFixed(2)).toLocaleString()}`
56 | }))
57 | } else {
58 | setROIValue(prev => ({
59 | title: 'Total ROI',
60 | value: `${(currentField.farmingROI.allTimeROI * 100).toFixed(2)}%`
61 | }))
62 | }
63 | }
64 |
65 | if (!name) {
66 | history.push('/dashboard');
67 | return (<>>)
68 | }
69 |
70 | return (
71 |
72 |
73 |
{name} (farming)
74 |
Current nominal APY : {secondaryFarmingTokens ? mainAPY + ', ' + secondaryAPYs : mainAPY}
75 | {/* @dev: assumes there is a single staking token */}
76 |
Staking token : {currentField.seedTokens[0].name}
77 |
Underlying tokens : {underlyingTokens.reduce((acc, curr) => [...acc, curr.name], []).join(', ')}
78 |
79 |
80 |
81 |
82 |
83 |
{ROIValue.title}
84 |
{ROIValue.value}
85 |
86 |
87 |
88 |
89 |
{lockedValue.title} investment value
90 |
${lockedValue.value}
91 |
92 |
93 |
94 |
95 |
96 |
Source of ROI
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
Transaction history
106 |
107 |
108 |
109 |
110 |
111 | )
112 | }
--------------------------------------------------------------------------------
/client/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl, {
104 | headers: { 'Service-Worker': 'script' },
105 | })
106 | .then(response => {
107 | // Ensure service worker exists, and that we really are getting a JS file.
108 | const contentType = response.headers.get('content-type');
109 | if (
110 | response.status === 404 ||
111 | (contentType != null && contentType.indexOf('javascript') === -1)
112 | ) {
113 | // No service worker found. Probably a different app. Reload the page.
114 | navigator.serviceWorker.ready.then(registration => {
115 | registration.unregister().then(() => {
116 | window.location.reload();
117 | });
118 | });
119 | } else {
120 | // Service worker found. Proceed as normal.
121 | registerValidSW(swUrl, config);
122 | }
123 | })
124 | .catch(() => {
125 | console.log(
126 | 'No internet connection found. App is running in offline mode.'
127 | );
128 | });
129 | }
130 |
131 | export function unregister() {
132 | if ('serviceWorker' in navigator) {
133 | navigator.serviceWorker.ready
134 | .then(registration => {
135 | registration.unregister();
136 | })
137 | .catch(error => {
138 | console.error(error.message);
139 | });
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/client/src/containers/EarningFieldDetails/EarningFieldDetails.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useRef } from 'react';
2 | import './EarningFieldDetails.css';
3 | import DetailsTable from '../../components/DetailsTable/DetailsTable';
4 | import DetailsBarChart from '../../components/DetailsBarChart/DetailsBarChart';
5 | import MaxiToggle from '../../components/MaxiToggle/MaxiToggle';
6 | import MiniToggle from '../../components/MiniToggle/MiniToggle';
7 | import helpers from '../../helpers';
8 |
9 | export default function EarningFieldDetails ({name, userFields, history}) {
10 | const [currentField] = useState(userFields.find(field => field.name === name));
11 | const [farmingFields, setFarmingFields] = useState([]);
12 | const [combinedfields, setCombinedFields] = useState({currentField: null, farmingFields: []});
13 | const [combinedROI, setCombinedROI] = useState({roi: 0, abs: 0});
14 | const [combinedFlag, setCombinedFlag] = useState(false);
15 | const [displayAbsROIValue, setDisplayAbsROIValue] = useState(false);
16 | const [displayHistInv, setDisplayHistInv] = useState(false);
17 | const [ROIValue, setROIValue] = useState({title: 'ROI', value: '0%'});
18 | const [invValue, setInvValue] = useState({title: 'Current', value: '$0'})
19 | const roiRef = useRef(null);
20 | const combinedGraph = useRef(null);
21 |
22 | function toggleCombinedROI(e) {
23 | const graphStyle = combinedGraph.current.style;
24 | if (e.target.checked) {
25 | setCombinedFlag(true);
26 | graphStyle.display = 'flex';
27 | graphStyle.animation = 'growDown 300ms ease-in-out forwards';
28 | } else {
29 | setCombinedFlag(false);
30 | graphStyle.animation = 'shrinkUp 300ms ease-in-out forwards';
31 | setTimeout(() => graphStyle.display = 'none', 300);
32 | }
33 |
34 | roiRef.current.className += ' roi-pulse';
35 | setTimeout(() => roiRef.current.className = 'field-overview-value', 300)
36 | }
37 |
38 | function toggleDisplay(e, target) {
39 | if (e.target.checked) {
40 | target === 'roi' ? setDisplayAbsROIValue(true) : setDisplayHistInv(true);
41 | } else {
42 | target === 'roi' ? setDisplayAbsROIValue(false) : setDisplayHistInv(false);
43 | }
44 | }
45 |
46 | useEffect(() => {
47 | window.scrollTo(0, 0);
48 | if (name) {
49 | const targetFarms = userFields.filter(field => field.seedTokens[0].tokenId === currentField.receiptToken)
50 | setFarmingFields(targetFarms);
51 | setCombinedFields({earningField: currentField, farmingFields: targetFarms});
52 | setCombinedROI(helpers.calcCombinedROI({earningField: currentField, farmingFields: targetFarms}));
53 | }
54 | }, [currentField]) //eslint-disable-line react-hooks/exhaustive-deps
55 |
56 | useEffect(() => {
57 | if (name) {
58 | if (displayAbsROIValue) {
59 | if (combinedFlag) {
60 | setROIValue({title: 'return value', value: '$' + Number(combinedROI.abs.toFixed()).toLocaleString()})
61 | } else {
62 | setROIValue({title: 'return value', value: '$' + Number(currentField.earningROI.absReturnValue.toFixed()).toLocaleString()})
63 | }
64 | } else {
65 | if (combinedFlag) {
66 | setROIValue({title: 'ROI', value: (combinedROI.roi * 100).toFixed(2) + '%'})
67 | } else {
68 | setROIValue({title: 'ROI', value: (currentField.earningROI.allTimeROI * 100).toFixed(2) + '%'})
69 | }
70 | }
71 | }
72 | //eslint-disable-next-line react-hooks/exhaustive-deps
73 | }, [displayAbsROIValue, combinedFlag])
74 |
75 | useEffect(() => {
76 | if (name) {
77 | if (displayHistInv) {
78 | setInvValue({title: 'Average historic', value: '$' + Number(currentField.earningROI.histInvestmentValue.toFixed()).toLocaleString()})
79 | } else {
80 | setInvValue({title: 'Current', value: '$' + Number(currentField.investmentValue.toFixed()).toLocaleString()})
81 | }
82 | }
83 | //eslint-disable-next-line react-hooks/exhaustive-deps
84 | }, [displayHistInv])
85 |
86 | if (!name) {
87 | history.push('/dashboard');
88 | return (<>>)
89 | }
90 |
91 | return (
92 |
93 |
94 |
{name} {currentField.isEarning ? '(earning)' : '(farming)'}
95 |
Parent protocol: {currentField.protocol.name}
96 |
Current nominal APY: {currentField.earningAPY ? (currentField.earningAPY*100).toFixed(2) : (currentField.farmingAPY*100).toFixed(2)}%
97 |
Underlying tokens: {currentField.seedTokens.reduce((acc, curr) => [...acc, curr.name], []).join(', ')}
98 |
Linked farming fields: {farmingFields.reduce((acc, curr) => [...acc, curr.name], []).join(', ')}
99 |
100 |
101 |
102 |
Add farming ROI
103 |
104 |
105 |
106 |
107 |
108 |
Total {ROIValue.title}
109 |
{ROIValue.value}
110 |
toggleDisplay(e, 'roi')} />
111 |
112 |
113 |
114 |
{invValue.title} investment value
115 |
{invValue.value}
116 |
toggleDisplay(e, 'inv')} />
117 |
118 |
119 |
120 |
121 |
Combined earning and Farming returns
122 |
123 |
124 |
125 |
126 |
127 |
128 |
Transaction history
129 |
130 |
131 |
132 |
133 |
134 | )
135 | }
136 |
--------------------------------------------------------------------------------