Skip to content

Commit

Permalink
✨ feat(multi-asset): add daily price multi-asset support
Browse files Browse the repository at this point in the history
  • Loading branch information
Alex-Amarandei committed Jun 24, 2024
1 parent 2719c40 commit 95ab70f
Show file tree
Hide file tree
Showing 15 changed files with 218 additions and 56 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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!
Expand Down
32 changes: 32 additions & 0 deletions gulo-be/token_price_lambda/api.py
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions gulo-be/token_price_lambda/lambda_handler.py
Original file line number Diff line number Diff line change
@@ -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})
41 changes: 41 additions & 0 deletions gulo-be/token_price_lambda/s3_cache.py
Original file line number Diff line number Diff line change
@@ -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)}")
12 changes: 12 additions & 0 deletions gulo-be/token_price_lambda/utils.py
Original file line number Diff line number Diff line change
@@ -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),
}
33 changes: 33 additions & 0 deletions gulo-fe/src/api/token/get-token-price.ts
Original file line number Diff line number Diff line change
@@ -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<number> => {
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');
}
};
20 changes: 14 additions & 6 deletions gulo-fe/src/app/reports/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
Expand Down Expand Up @@ -100,9 +102,12 @@ export default function ReportsPage() {
</div>
</div>
<div className='flex gap-4'>
<div className='text-slate-100 bg-gradient-to-br from-gray-600 to-gray-700 rounded-lg transform transition-transform duration-300 hover:scale-105 cursor-pointer px-4 py-2 mb-2 font-bold'>
<div
className={`text-slate-100 bg-gradient-to-br from-gray-600 to-gray-700 rounded-lg transform transition-transform duration-300 ${selectedStreams.length === 0 ? 'cursor-not-allowed opacity-50' : 'hover:scale-105 cursor-pointer'} px-4 py-2 mb-2 font-bold`}>
<DropdownMenu>
<DropdownMenuTrigger className='focus:ring-0 focus:outline-none focus:ring-transparent flex items-center'>
<DropdownMenuTrigger
className='focus:ring-0 focus:outline-none focus:ring-transparent flex items-center'
disabled={selectedStreams.length === 0}>
Download
<svg
className='ml-2 w-4 h-4'
Expand All @@ -124,9 +129,12 @@ export default function ReportsPage() {
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className='text-slate-100 bg-gradient-to-br from-gray-600 to-gray-700 rounded-lg transform transition-transform duration-300 hover:scale-105 cursor-pointer px-4 py-2 mb-2 font-bold'>
<div
className={`text-slate-100 bg-gradient-to-br from-gray-600 to-gray-700 rounded-lg transform transition-transform duration-300 ${selectedStreams.length === 0 ? 'cursor-not-allowed opacity-50' : 'hover:scale-105 cursor-pointer'} px-4 py-2 mb-2 font-bold`}>
<DropdownMenu>
<DropdownMenuTrigger className='focus:ring-0 focus:outline-none focus:ring-transparent flex items-center'>
<DropdownMenuTrigger
className='focus:ring-0 focus:outline-none focus:ring-transparent flex items-center'
disabled={selectedStreams.length === 0}>
Email
<svg
className='ml-2 w-4 h-4'
Expand Down
16 changes: 13 additions & 3 deletions gulo-fe/src/components/molecules/content/balance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import DatePickerModal from '@/components/molecules/modals/date-picker-modal';
import { useStreams } from '@/components/templates/contexts/streams-context';
import getBalance from '@/utils/balances';
import { Maybe } from '@/utils/data';
import { formatBalance } from '@/utils/formats';
import { format } from 'date-fns';

export default function Balance() {
Expand Down Expand Up @@ -40,6 +41,8 @@ export default function Balance() {
return () => clearInterval(interval);
}, [selectedStreams, timestampChosenManually, date]);

const { integerPart, decimalPart } = formatBalance(balance);

return (
<div className='balance-square'>
<div
Expand All @@ -49,10 +52,17 @@ export default function Balance() {
{date ? format(date, 'LLL dd, y HH:mm:ss') : 'NOW'}
</span>
</div>
<div className='balance-rectangle-2 flex items-center justify-center text-center text-6xl text-slate-100'>
<strong>{balance}</strong>
<div className='balance-rectangle-2 flex items-center justify-center text-slate-100'>
<div className='flex items-end'>
<div className='flex items-center'>
<strong className='text-6xl'>{integerPart}</strong>
</div>
<div className='flex items-end'>
<strong className='text-3xl'>.{decimalPart}</strong>
</div>
</div>
</div>
<div className='balance-rectangle-3 flex items-center justify-center text-center text-2xl text-slate-100'>
<div className='balance-rectangle-3 flex items-center justify-center text-center text-lg text-slate-100'>
<strong>USD</strong>
</div>
{isModalOpen && <DatePickerModal date={date} onClose={handleCloseModal} onDateChange={setDate} />}
Expand Down
2 changes: 1 addition & 1 deletion gulo-fe/src/components/organisms/graph/stream-graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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! 🪣',
);
Expand Down
14 changes: 10 additions & 4 deletions gulo-fe/src/components/templates/streams.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -59,7 +63,9 @@ export default function Streams() {
return (
currentRoute !== '/graph' && (
<div
className={`rounded-lg overflow-auto p-4 transition-all duration-300 shadow-2xl h-[90vh] ${isStreamsCollapsed ? 'w-16' : 'w-1/3'} btn ${isStreamsCollapsed ? 'cursor-pointer z-50 absolute' : 'cursor-default'}`}
className={`rounded-lg overflow-auto p-4 transition-all duration-300 shadow-2xl h-[90vh] ${
isStreamsCollapsed ? 'w-16' : 'w-1/3'
} btn ${isStreamsCollapsed ? 'cursor-pointer z-50 absolute' : 'cursor-default'}`}
onClick={() => {
if (isStreamsCollapsed) {
setIsStreamsCollapsed(false);
Expand Down
1 change: 1 addition & 0 deletions gulo-fe/src/interfaces/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export interface Stream {
contract: Contract;
color: string;
isSelected: boolean;
assetPrice: number;
}

export interface StreamContextType {
Expand Down
13 changes: 5 additions & 8 deletions gulo-fe/src/utils/balances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,6 @@ export default function getBalance(streams: Stream[], date: Maybe<Date>): 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;
}
Expand All @@ -143,10 +139,11 @@ export default function getBalance(streams: Stream[], date: Maybe<Date>): 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<DateRange>) {
Expand All @@ -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();
}
2 changes: 1 addition & 1 deletion gulo-fe/src/utils/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading

0 comments on commit 95ab70f

Please sign in to comment.