From 95ab70fdb7a35263f7ec304fa45296336401650e Mon Sep 17 00:00:00 2001 From: Alex Amarandei Date: Tue, 25 Jun 2024 00:50:44 +0300 Subject: [PATCH] :sparkles: feat(multi-asset): add daily price multi-asset support --- README.md | 8 ++-- gulo-be/token_price_lambda/api.py | 32 +++++++++++++++ gulo-be/token_price_lambda/lambda_handler.py | 41 +++++++++++++++++++ gulo-be/token_price_lambda/s3_cache.py | 41 +++++++++++++++++++ gulo-be/token_price_lambda/utils.py | 12 ++++++ gulo-fe/src/api/token/get-token-price.ts | 33 +++++++++++++++ gulo-fe/src/app/reports/page.tsx | 20 ++++++--- .../components/molecules/content/balance.tsx | 16 ++++++-- .../organisms/graph/stream-graph.tsx | 2 +- gulo-fe/src/components/templates/streams.tsx | 14 +++++-- gulo-fe/src/interfaces/stream.ts | 1 + gulo-fe/src/utils/balances.ts | 13 +++--- gulo-fe/src/utils/data.ts | 2 +- gulo-fe/src/utils/formats.ts | 10 +++++ gulo-fe/src/utils/graph/connected.ts | 29 ------------- 15 files changed, 218 insertions(+), 56 deletions(-) create mode 100644 gulo-be/token_price_lambda/api.py create mode 100644 gulo-be/token_price_lambda/lambda_handler.py create mode 100644 gulo-be/token_price_lambda/s3_cache.py create mode 100644 gulo-be/token_price_lambda/utils.py create mode 100644 gulo-fe/src/api/token/get-token-price.ts delete mode 100644 gulo-fe/src/utils/graph/connected.ts diff --git a/README.md b/README.md index f0553cd..8c2bc43 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ However, many exciting features will be shipped in the coming weeks! Keep an eye - **Stream Network Graph View:** Visualise the entire Sablier Stream Network on the chain of your choice! Although they might take a bit to load, they are up to date and worth the wait! ⏳ +### 💸 Multi-Asset Support was Shipped 🆕 + +- **Multi-Asset Support:** All of your streams' tokens are now taken into account at their first fetched daily price. See how your portoflio's value evolves over time given the current market situation! 📈 + ## 🌱 Why was Gulo created? The Sablier Team has revolutionized the concept of money streaming in the Ethereum Ecosystem. Gulo represents a helping hand from an eager supporter aspiring to become a full-time Web3 builder. This project is my first endeavor in this direction. @@ -39,10 +43,6 @@ Hi, I'm Alex, and I'm the sole developer working on Gulo. The Roadmap is as follows: -### 📅 June - -- **Multi-Currency Support:** Currently, only stablecoins (DAI, USDC, USDT) are supported, but all ERC-20 tokens will be included soon! - ### 📅 July - **Spy:** View the same info you can see for yourself, for any address of your choice! diff --git a/gulo-be/token_price_lambda/api.py b/gulo-be/token_price_lambda/api.py new file mode 100644 index 0000000..cd37c58 --- /dev/null +++ b/gulo-be/token_price_lambda/api.py @@ -0,0 +1,32 @@ +import os +import requests +import logging + +CMC_API_KEY = os.environ["CMC_API_KEY"] +CMC_API_URL = os.environ.get( + "CMC_API_URL", + "https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest", +) + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def fetch_token_price_from_cmc(token_ticker): + headers = {"X-CMC_PRO_API_KEY": CMC_API_KEY} + params = {"symbol": token_ticker, "convert": "USD"} + + logger.info(f"Fetching price for {token_ticker} from CoinMarketCap") + + response = requests.get(CMC_API_URL, headers=headers, params=params) + data = response.json() + + if response.status_code != 200 or "data" not in data: + logger.error( + f"Failed to fetch price for {token_ticker} from CoinMarketCap: {data}" + ) + return None + + price = data["data"][token_ticker]["quote"]["USD"]["price"] + logger.info(f"Fetched price for {token_ticker}: {price}") + return price diff --git a/gulo-be/token_price_lambda/lambda_handler.py b/gulo-be/token_price_lambda/lambda_handler.py new file mode 100644 index 0000000..191817a --- /dev/null +++ b/gulo-be/token_price_lambda/lambda_handler.py @@ -0,0 +1,41 @@ +import os +from datetime import datetime, timezone +from s3_cache import get_token_price_from_s3, save_token_price_to_s3 +from api import fetch_token_price_from_cmc +from utils import create_response +import logging + +BUCKET_NAME = os.environ["S3_BUCKET_NAME"] + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def lambda_handler(event, context): + query_params = event.get("queryStringParameters", {}) + ticker = query_params.get("ticker") + if not ticker: + logger.error("Missing ticker in the request") + return create_response(400, "Missing ticker in the request") + + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + s3_key = f"tokens/{ticker}/{today}.txt" + + logger.info(f"Checking S3 for cached price of {ticker} on {today}") + + token_price = get_token_price_from_s3(BUCKET_NAME, s3_key) + if token_price is not None: + logger.info(f"Found cached price for {ticker}: {token_price}") + return create_response(200, {"price": token_price}) + + logger.info(f"Cached price for {ticker} not found, fetching from CoinMarketCap") + + token_price = fetch_token_price_from_cmc(ticker) + if token_price is None: + logger.error(f"Failed to fetch price for {ticker} from CoinMarketCap") + return create_response(500, "Failed to fetch price from CoinMarketCap") + + save_token_price_to_s3(BUCKET_NAME, s3_key, token_price) + logger.info(f"Saved price for {ticker} to S3: {token_price}") + + return create_response(200, {"price": token_price}) diff --git a/gulo-be/token_price_lambda/s3_cache.py b/gulo-be/token_price_lambda/s3_cache.py new file mode 100644 index 0000000..fb82210 --- /dev/null +++ b/gulo-be/token_price_lambda/s3_cache.py @@ -0,0 +1,41 @@ +import os +import boto3 +import logging + +aws_access_key_id = os.getenv("ACCESS_KEY_ID") +aws_secret_access_key = os.getenv("SECRET_ACCESS_KEY") +s3_region = os.getenv("S3_REGION", "eu-central-1") + +s3_client = boto3.client( + "s3", + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + region_name=s3_region, +) + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def get_token_price_from_s3(bucket_name, key): + try: + logger.info(f"Fetching {key} from bucket {bucket_name}") + response = s3_client.get_object(Bucket=bucket_name, Key=key) + token_price = response["Body"].read().decode("utf-8") + logger.info(f"Fetched price from S3: {token_price}") + return token_price + except s3_client.exceptions.NoSuchKey: + logger.warning(f"{key} not found in bucket {bucket_name}") + return None + except Exception as e: + logger.error(f"Error fetching {key} from bucket {bucket_name}: {str(e)}") + return None + + +def save_token_price_to_s3(bucket_name, key, token_price): + try: + logger.info(f"Saving {key} to bucket {bucket_name} with price {token_price}") + s3_client.put_object(Bucket=bucket_name, Key=key, Body=str(token_price)) + logger.info(f"Saved price {token_price} to S3 at {key}") + except Exception as e: + logger.error(f"Error saving {key} to bucket {bucket_name}: {str(e)}") diff --git a/gulo-be/token_price_lambda/utils.py b/gulo-be/token_price_lambda/utils.py new file mode 100644 index 0000000..f30a88a --- /dev/null +++ b/gulo-be/token_price_lambda/utils.py @@ -0,0 +1,12 @@ +import json + + +def create_response(status_code, body): + return { + "statusCode": status_code, + "headers": { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET", + }, + "body": json.dumps(body), + } diff --git a/gulo-fe/src/api/token/get-token-price.ts b/gulo-fe/src/api/token/get-token-price.ts new file mode 100644 index 0000000..fd9c2b5 --- /dev/null +++ b/gulo-fe/src/api/token/get-token-price.ts @@ -0,0 +1,33 @@ +import dotenv from 'dotenv'; +import { toast } from 'sonner'; + +dotenv.config(); + +const API_GATEWAY_ENDPOINT = process.env.API_GATEWAY_ENDPOINT; + +export const getTokenPrice = async (tokenTicker: string): Promise => { + if (['DAI', 'USDC', 'USDT'].includes(tokenTicker)) { + return 1; + } + + try { + const response = await fetch(`${API_GATEWAY_ENDPOINT}/token/price?ticker=${tokenTicker}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch price for ${tokenTicker}: ${response.statusText}`); + } + + const data: { price: number | string } = await response.json(); + return Number(data.price); + } catch (error) { + toast.error( + "Uh oh! 😞 We couldn't get price data for all of your tokens right now. Please try refreshing the page or check back later. 🔄", + ); + throw new Error('Failed to fetch token price'); + } +}; diff --git a/gulo-fe/src/app/reports/page.tsx b/gulo-fe/src/app/reports/page.tsx index 1dfb3c7..f6c451d 100644 --- a/gulo-fe/src/app/reports/page.tsx +++ b/gulo-fe/src/app/reports/page.tsx @@ -52,11 +52,13 @@ export default function ReportsPage() { const toggleStartModal = () => setIsModalOpen(prev => !prev); const handleDownload = (type: DownloadType) => { - downloadTable(selectedStreams, balanceType, date, dateRange, type); + if (selectedStreams.length > 0) { + downloadTable(selectedStreams, balanceType, date, dateRange, type); + } }; const handleEmailDownload = (email: string) => { - if (selectedDownloadType) { + if (selectedDownloadType && selectedStreams.length > 0) { downloadTable(selectedStreams, balanceType, date, dateRange, selectedDownloadType, email); } }; @@ -100,9 +102,12 @@ export default function ReportsPage() {
-
+
- + Download
-
+
- + Email clearInterval(interval); }, [selectedStreams, timestampChosenManually, date]); + const { integerPart, decimalPart } = formatBalance(balance); + return (
-
- {balance} +
+
+
+ {integerPart} +
+
+ .{decimalPart} +
+
-
+
USD
{isModalOpen && } diff --git a/gulo-fe/src/components/organisms/graph/stream-graph.tsx b/gulo-fe/src/components/organisms/graph/stream-graph.tsx index a714aa2..035aa9e 100644 --- a/gulo-fe/src/components/organisms/graph/stream-graph.tsx +++ b/gulo-fe/src/components/organisms/graph/stream-graph.tsx @@ -58,7 +58,7 @@ export const StreamGraph = ({ chainId }: { chainId: number }) => { ); } }) - .catch(error => { + .catch(() => { toast.error( 'The Streams have overflown! 🌊 Just refresh the page, and we will take care of the cleaning! 🪣', ); diff --git a/gulo-fe/src/components/templates/streams.tsx b/gulo-fe/src/components/templates/streams.tsx index d7a265b..57f334c 100644 --- a/gulo-fe/src/components/templates/streams.tsx +++ b/gulo-fe/src/components/templates/streams.tsx @@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from 'react'; import fetchNftDetails from '@/api/streams/fetch-nft-details'; import { fetchUserStreams } from '@/api/streams/fetch-user-streams'; +import { getTokenPrice } from '@/api/token/get-token-price'; import FilterButton from '@/components/atoms/buttons/filter-button'; import StreamList from '@/components/molecules/content/stream-list'; import { useStreams } from '@/components/templates/contexts/streams-context'; @@ -34,23 +35,26 @@ export default function Streams() { useEffect(() => { const fetchData = async () => { const streams = await fetchUserStreams(); - const coloredStreams: Stream[] = await Promise.all( + const upgradedStreams: Stream[] = await Promise.all( streams .filter((stream: StreamData) => !isCircular(stream)) .map(async (stream: StreamData) => { const nft = await fetchNftDetails(stream); const color = getNftColor(nft); const rebasedStream = rebaseStream(stream); + const assetPrice = await getTokenPrice(stream.asset.symbol); setStreamNftMap(prevState => ({ ...prevState, [stream.alias]: nft })); + return { ...rebasedStream, color: color, isSelected: true, + assetPrice: assetPrice, }; }), ); - setStreams(coloredStreams); - setSelectedStreams(coloredStreams); + setStreams(upgradedStreams); + setSelectedStreams(upgradedStreams); }; fetchData(); @@ -59,7 +63,9 @@ export default function Streams() { return ( currentRoute !== '/graph' && (
{ if (isStreamsCollapsed) { setIsStreamsCollapsed(false); diff --git a/gulo-fe/src/interfaces/stream.ts b/gulo-fe/src/interfaces/stream.ts index ad68d44..c8acaae 100644 --- a/gulo-fe/src/interfaces/stream.ts +++ b/gulo-fe/src/interfaces/stream.ts @@ -99,6 +99,7 @@ export interface Stream { contract: Contract; color: string; isSelected: boolean; + assetPrice: number; } export interface StreamContextType { diff --git a/gulo-fe/src/utils/balances.ts b/gulo-fe/src/utils/balances.ts index 1017218..3f57767 100644 --- a/gulo-fe/src/utils/balances.ts +++ b/gulo-fe/src/utils/balances.ts @@ -131,10 +131,6 @@ export default function getBalance(streams: Stream[], date: Maybe): string const timestamp = date ? Math.floor(date.getTime() / 1000) : timestampNow; streams.forEach(stream => { - if (!['DAI', 'USDC', 'USDT'].includes(stream.asset.symbol)) { - return; - } - if (hasNotStarted(stream, timestamp) || isCircular(stream)) { return; } @@ -143,10 +139,11 @@ export default function getBalance(streams: Stream[], date: Maybe): string ? getIncomingStreamBalance(stream, timestamp, timestampNow) : getOutgoingStreamBalance(stream, timestamp); - entitledAmount = entitledAmount.plus(balance); + const value = balance.times(stream.assetPrice); + entitledAmount = entitledAmount.plus(value); }); - return entitledAmount.toFixed(4).toString(); + return entitledAmount.toFixed(6).toString(); } export function getStreamedAmountForDateRange(stream: Stream, dateRange: Maybe) { @@ -160,8 +157,8 @@ export function getRemainingAmount(stream: Stream): string { const address = getAccount(WAGMI_CONFIG).address; if (isIncoming(stream, address)) { - return stream.intactAmount.toFixed(4).toString(); + return stream.intactAmount.times(stream.assetPrice).toFixed(6).toString(); } - return stream.intactAmount.plus(stream.withdrawnAmount).times(-1).toFixed(4).toString(); + return stream.intactAmount.plus(stream.withdrawnAmount).times(-stream.assetPrice).toFixed(6).toString(); } diff --git a/gulo-fe/src/utils/data.ts b/gulo-fe/src/utils/data.ts index 254392e..5e08b72 100644 --- a/gulo-fe/src/utils/data.ts +++ b/gulo-fe/src/utils/data.ts @@ -122,7 +122,7 @@ function getHexFromHsl(hsl: string, forceRemoveAlpha = true): string { throw new Error('Invalid HSL format'); } - const [_, h, s, l, a] = hslMatch; + const [, h, s, l, a] = hslMatch; const hValue = parseInt(h, 10); const sValue = parseFloat(s) / 100; diff --git a/gulo-fe/src/utils/formats.ts b/gulo-fe/src/utils/formats.ts index 7fcb884..07cb634 100644 --- a/gulo-fe/src/utils/formats.ts +++ b/gulo-fe/src/utils/formats.ts @@ -47,6 +47,7 @@ export function rebaseStream(stream: StreamData): Stream { withdrawnAmount: rebase(BigNumber(stream.withdrawnAmount)), color: '', isSelected: true, + assetPrice: 1, }; } @@ -104,3 +105,12 @@ export function getCacheKey(chainId: number, key: string): string { export function getSablierSearchByChainIdAndAddress(chainId: number, address: string): string { return `https://app.sablier.com/?t=search&c=${chainId}&s=${address}&r=${address}`; } + +export const formatBalance = (balance: string): { integerPart: string; decimalPart: string } => { + const [integerPart, decimalPart] = balance.split('.'); + const formattedIntegerPart = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return { + integerPart: formattedIntegerPart, + decimalPart: decimalPart || '0000', + }; +}; diff --git a/gulo-fe/src/utils/graph/connected.ts b/gulo-fe/src/utils/graph/connected.ts deleted file mode 100644 index 1f806a4..0000000 --- a/gulo-fe/src/utils/graph/connected.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Edge, Node } from 'react-vis-network-graph'; - -export const findConnectedComponents = (nodes: Node[], edges: Edge[]): Node[][] => { - const visited = new Set(); - const components: Node[][] = []; - - const dfs = (nodeId: string, component: Node[]) => { - visited.add(nodeId); - component.push(nodes.find(node => node.id === nodeId)!); - - edges.forEach(edge => { - if (edge.from === nodeId && !visited.has(String(edge.to))) { - dfs(String(edge.to), component); - } else if (edge.to === nodeId && !visited.has(String(edge.from))) { - dfs(String(edge.from), component); - } - }); - }; - - nodes.forEach(node => { - if (!visited.has(String(node.id))) { - const component: Node[] = []; - dfs(String(node.id), component); - components.push(component); - } - }); - - return components; -};