diff --git a/.env.example b/.env.example index d9302c5..0e59d47 100644 --- a/.env.example +++ b/.env.example @@ -21,4 +21,5 @@ SUGAR_TOKENS_CACHE_MINUTES=10 # caching for Sugar LPs SUGAR_LPS_CACHE_MINUTES=10 # caching for oracle pricing -ORACLE_PRICES_CACHE_MINUTES=10 \ No newline at end of file +ORACLE_PRICES_CACHE_MINUTES=10 +UI_POOL_STATS_THUMBNAIL=https://i.imgur.com/lGbVYac.png \ No newline at end of file diff --git a/.gitignore b/.gitignore index f5412c8..fbea775 100644 --- a/.gitignore +++ b/.gitignore @@ -160,4 +160,7 @@ cython_debug/ #.idea/ # VS Code -.vscode \ No newline at end of file +.vscode + +# Mac +.DS_Store \ No newline at end of file diff --git a/bots/commander.py b/bots/commander.py index ecfd6e7..d27e011 100644 --- a/bots/commander.py +++ b/bots/commander.py @@ -1,4 +1,4 @@ -from .data import LiquidityPool +from .data import LiquidityPool, LiquidityPoolEpoch from .helpers import is_address from .ui import PoolsDropdown, PoolStats @@ -7,6 +7,8 @@ class _CommanderBot(commands.Bot): + """Commander bot instance to handle / commands""" + def __init__(self): intents = discord.Intents.default() intents.message_content = True @@ -22,21 +24,29 @@ async def on_ready(self): def CommanderBot() -> commands.Bot: + # keep our commander as a singleton return bot async def on_select_pool( - response: discord.InteractionResponse, + interaction: discord.Interaction, address_or_pool: str | LiquidityPool, ): + """Handle pool selection and reply with a pool stats embed + + Args: + interaction (discord.Interaction): chat interaction + address_or_pool (str | LiquidityPool): pool address or instance + """ pool = ( await LiquidityPool.by_address(address_or_pool) if isinstance(address_or_pool, str) else address_or_pool ) tvl = await LiquidityPool.tvl([pool]) - await response.send_message( - await PoolStats().render(pool, tvl), suppress_embeds=True + pool_epoch = await LiquidityPoolEpoch.fetch_for_pool(pool.lp) + await interaction.response.send_message( + embed=await PoolStats(interaction.client.emojis).render(pool, tvl, pool_epoch) ) @@ -45,11 +55,21 @@ async def on_select_pool( address_or_query="Pool address or search query", ) async def pool(interaction: discord.Interaction, address_or_query: str): + """Pool command handler: show specific pool or pool selector + + Args: + interaction (discord.Interaction): chat interaction + address_or_query (str): command input + """ if is_address(address_or_query): + # if /pool receives specific pool address, + # show the pool immediately or show in error + # message if it does not exist + pool = await LiquidityPool.by_address(address_or_query) if pool is not None: - await on_select_pool(interaction.response, pool) + await on_select_pool(interaction, pool) else: await interaction.response.send_message( f"No pool found with this address: {address_or_query}" @@ -58,6 +78,15 @@ async def pool(interaction: discord.Interaction, address_or_query: str): pools = await LiquidityPool.search(address_or_query) + if len(pools) == 1: + # got exact match, show the pool + await on_select_pool(interaction, pools[0]) + return + + # search returned several pools, show them in a dropdown await interaction.response.send_message( - "Choose a pool:", view=PoolsDropdown(pools=pools, callback=on_select_pool) + "Choose a pool:", + view=PoolsDropdown( + interaction=interaction, pools=pools, callback=on_select_pool + ), ) diff --git a/bots/data.py b/bots/data.py index a9f164b..70fb754 100644 --- a/bots/data.py +++ b/bots/data.py @@ -212,6 +212,7 @@ class LiquidityPool: gauge_total_supply: float emissions: Amount emissions_token: Token + weekly_emissions: Amount @classmethod def from_tuple( @@ -224,6 +225,8 @@ def from_tuple( emissions_token = normalize_address(t[18]) emissions = t[17] + seconds_in_a_week = 7 * 24 * 60 * 60 + return LiquidityPool( lp=normalize_address(t[0]), symbol=t[1], @@ -240,6 +243,9 @@ def from_tuple( gauge_total_supply=t[12], emissions_token=tokens.get(emissions_token), emissions=Amount.build(emissions_token, emissions, tokens, prices), + weekly_emissions=Amount.build( + emissions_token, emissions * seconds_in_a_week, tokens, prices + ), ) @classmethod @@ -277,10 +283,18 @@ async def search(cls, query: str, limit: int = 10) -> List["LiquidityPool"]: def match_score(query: str, symbol: str): return fuzz.token_sort_ratio(query, symbol) + query_lowercase = query.lower() pools = await cls.get_pools() pools = list( filter(lambda p: p.token0 is not None and p.token1 is not None, pools) ) + + # look for exact match first, i.e. we get proper pool symbol in query (case insensitive) + exact_match = list(filter(lambda p: p.symbol.lower() == query_lowercase, pools)) + + if len(exact_match) == 1: + return exact_match + pools_with_ratio = list(map(lambda p: (p, match_score(query, p.symbol)), pools)) pools_with_ratio.sort(key=lambda p: p[1], reverse=True) @@ -401,6 +415,15 @@ async def fetch_latest(cls): return result + @classmethod + async def fetch_for_pool(cls, pool_address: str) -> "LiquidityPoolEpoch": + pool_epochs = await cls.fetch_latest() + try: + a = normalize_address(pool_address) + return next(pe for pe in pool_epochs if pe.pool_address == a) + except Exception: + return None + @property def total_fees(self) -> float: return sum(map(lambda fee: fee.amount_in_stable, self.fees)) diff --git a/bots/settings.py b/bots/settings.py index 0cad638..f1c3d45 100644 --- a/bots/settings.py +++ b/bots/settings.py @@ -41,3 +41,5 @@ ORACLE_PRICES_CACHE_MINUTES = int(os.environ["ORACLE_PRICES_CACHE_MINUTES"]) GOOD_ENOUGH_PAGINATION_LIMIT = 2000 + +UI_POOL_STATS_THUMBNAIL = os.environ["UI_POOL_STATS_THUMBNAIL"] diff --git a/bots/ui/__init__.py b/bots/ui/__init__.py index 5099191..02c9983 100644 --- a/bots/ui/__init__.py +++ b/bots/ui/__init__.py @@ -1,2 +1,3 @@ from .pools import PoolsDropdown # noqa from .pool_stats import PoolStats # noqa +from .emojis import Emojis # noqa diff --git a/bots/ui/emojis.py b/bots/ui/emojis.py new file mode 100644 index 0000000..2daf179 --- /dev/null +++ b/bots/ui/emojis.py @@ -0,0 +1,30 @@ +from typing import Sequence +from discord import Emoji + + +class Emojis: + """Loads all custom emojis associated with current server and + makes them available via get method""" + + def __init__(self, emojis: Sequence[Emoji]): + """Load all custom emojis + + Args: + emojis (Sequence[Emoji]): custom emojis attached to a server + """ + self.emojis = {} + for emoji in emojis: + # based on: https://github.com/Rapptz/discord.py/issues/390 + self.emojis[emoji.name] = str(emoji) + + def get(self, name: str, fallback: str = "*") -> str: + """Get custom emoji by name or return fallback string + + Args: + name (str): name of the custom emoji to get + fallback (str, optional): fallback value to return. Defaults to "*". + + Returns: + str: _description_ + """ + return self.emojis[name] if name in self.emojis else fallback diff --git a/bots/ui/pool_stats.py b/bots/ui/pool_stats.py index 4405247..147e71f 100644 --- a/bots/ui/pool_stats.py +++ b/bots/ui/pool_stats.py @@ -1,53 +1,181 @@ -from ..data import LiquidityPool +from typing import Sequence +import discord +from ..data import LiquidityPool, LiquidityPoolEpoch from ..helpers import format_percentage, format_currency, make_app_url -from ..settings import APP_BASE_URL +from ..settings import APP_BASE_URL, UI_POOL_STATS_THUMBNAIL, PROTOCOL_NAME +from .emojis import Emojis class PoolStats: - async def render(self, pool: LiquidityPool, tvl: float) -> str: + """Pool stats embded UI to visualize a pool; + for it to look nice make sure you load custom emojis to your Discord server""" + + def __init__(self, emojis: Sequence[discord.Emoji]): + self.emojis = Emojis(emojis) + + async def render( + self, pool: LiquidityPool, tvl: float, pool_epoch: LiquidityPoolEpoch + ) -> discord.Embed: + """renders pool stats into a discord.Embed + + Returns: + discord.Embed: discord embed UI ready to be sent + """ token0_fees = pool.token0_fees.amount_in_stable if pool.token0_fees else 0 token1_fees = pool.token1_fees.amount_in_stable if pool.token1_fees else 0 - template_args = { - "pool_symbol": pool.symbol, - "pool_fee_percentage": format_percentage(pool.pool_fee_percentage), - "apr": format_percentage(pool.apr(tvl)), - "tvl": format_currency(tvl), - "token0_volume": format_currency( - pool.reserve0.amount if pool.reserve0 else 0, - symbol=pool.token0.symbol, - prefix=False, - ), - "token1_volume": format_currency( - pool.reserve1.amount if pool.reserve1 else 0, - symbol=pool.token1.symbol, - prefix=False, + apr = pool.apr(tvl) + + deposit_url = make_app_url( + APP_BASE_URL, + "/deposit", + { + "token0": pool.token0.token_address.lower(), + "token1": pool.token1.token_address.lower(), + "stable": str(pool.is_stable).lower(), + }, + ) + + emojis = { + "dashgrey": self.emojis.get("dashgrey", "·"), + "dashwhite": self.emojis.get("dashwhite", "●"), + "volume": self.emojis.get("volume", ":droplet:"), + "apr": self.emojis.get("apr", ":chart_with_upwards_trend:"), + "incentives": self.emojis.get("incentives", ":carrot:"), + "emissions": self.emojis.get("emissions", ":zap:"), + "space": self.emojis.get("space", " "), + "deposit": self.emojis.get("deposit", ":pig2:"), + "coin": self.emojis.get("coinplaceholder", ":coin:"), + } + + token0_volume_coin = format_currency( + pool.reserve0.amount if pool.reserve0 else 0, + symbol=pool.token0.symbol, + prefix=False, + ) + token0_volume_stable = format_currency( + pool.reserve0.amount_in_stable if pool.reserve0 else 0 + ) + token1_volume_coin = format_currency( + pool.reserve1.amount if pool.reserve1 else 0, + symbol=pool.token1.symbol, + prefix=False, + ) + token1_volume_stable = format_currency( + pool.reserve1.amount_in_stable if pool.reserve1 else 0 + ) + emissions_coin = format_currency( + pool.weekly_emissions.amount, + symbol=pool.emissions.token.symbol, + prefix=False, + ) + emissions_stable = format_currency(pool.weekly_emissions.amount_in_stable) + + embed = discord.Embed() + + embed.set_thumbnail(url=UI_POOL_STATS_THUMBNAIL) + + # Header row with pool symbol + # and TVL + Fee Below + fee_percentage_str = format_percentage(pool.pool_fee_percentage) + embed.add_field( + name=f"{pool.symbol}", + value=f"TVL {format_currency(tvl)} · Fee: {fee_percentage_str}", + inline=False, + ) + + # vertical space + embed.add_field(name="", value=f"{emojis['space']}", inline=False) + + coin_icon = emojis["coin"] + + # token0 reserve: top row in coin + # bottom row in stable + embed.add_field( + name=f"{coin_icon} {token0_volume_coin}{emojis['space'] * 3}", + value=f"{emojis['dashgrey']} _~{token0_volume_stable}_", + inline=True, + ) + + # token1 reserve: top row in coin + # bottom row in stable + embed.add_field( + name=f"{coin_icon} {token1_volume_coin}", + value=f"{emojis['dashgrey']} _~{token1_volume_stable}_", + inline=True, + ) + + # vertical space + embed.add_field(name="", value="", inline=False) + + # Volume + # top row: pool volume + # bottom row: fees + volume_str = format_currency(pool.volume) + volume_with_fees_str = format_currency(token0_fees + token1_fees) + embed.add_field( + name=f"{emojis['volume']} Volume", + value=" ".join( + [ + f"{emojis['dashwhite']} {volume_str}\n{emojis['dashgrey']}", + f"_{volume_with_fees_str} in fees_", + ] ), - "volume": format_currency(pool.volume), - "fees": format_currency(token0_fees + token1_fees), - "deposit_url": make_app_url( - APP_BASE_URL, - "/deposit", - { - "token0": pool.token0.token_address, - "token1": pool.token1.token_address, - "stable": str(pool.is_stable).lower(), - }, + inline=True, + ) + + # Incentives + # top row: total bribes + # bottom row: total bribes + total fees + # XX: this seems OFF, should we pull this from LiquidityPoolEpoch instead? + incentives_str = format_currency(pool_epoch.total_bribes) + incentives_with_fees_str = format_currency( + pool_epoch.total_bribes + pool_epoch.total_fees + ) + embed.add_field( + name=f"{emojis['incentives']} Incentives", + value="\n".join( + [ + f"{emojis['dashwhite']} {incentives_str}", + f"{emojis['dashgrey']} _{incentives_with_fees_str} with fees_", + ] ), - "incentivize_url": make_app_url( - APP_BASE_URL, "/incentivize", {"pool": pool.lp} + inline=True, + ) + + # vertical space + embed.add_field(name="", value="", inline=False) + + # Emissions + # top row: emissions in coin + # bottom row: emissions in stable + embed.add_field( + name=f"{emojis['emissions']} Emissions", + value="\n".join( + [ + f"{emojis['dashwhite']} {emissions_coin}", + f"{emojis['dashgrey']} _~{emissions_stable}_", + ] ), - } + inline=True, + ) - return """ -> **{pool_symbol} ● Fee {pool_fee_percentage} ● {apr} APR** -> - ~{tvl} TVL -> - {token0_volume} -> - {token1_volume} -> - ~{volume} volume this epoch -> - ~{fees} fees this epoch -> -> [Deposit 🐖]({deposit_url}) ● [Incentivize 🙋]({incentivize_url}) -""".format( - **template_args + # APR + # APR in percent + embed.add_field( + name=f"{emojis['apr']} APR", + value=f"{emojis['dashwhite']} {format_percentage(apr)}", + inline=True, ) + + # vertical space + embed.add_field(name="", value="", inline=False) + + # link to deposit page + embed.add_field( + name="", + value=f"{emojis['deposit']} [Deposit on {PROTOCOL_NAME}]({deposit_url})", + inline=False, + ) + + return embed diff --git a/bots/ui/pools.py b/bots/ui/pools.py index f64db6a..3cd97fe 100644 --- a/bots/ui/pools.py +++ b/bots/ui/pools.py @@ -1,22 +1,29 @@ +from typing import List, Callable, Awaitable import discord from ..data import LiquidityPool -from typing import List, Callable, Awaitable +from .emojis import Emojis intents = discord.Intents.default() intents.message_content = True -def build_select_option(pool: LiquidityPool) -> discord.SelectOption: - return discord.SelectOption(label=pool.symbol, value=pool.lp, emoji="🏊‍♀️") +def build_select_option( + interaction: discord.Interaction, pool: LiquidityPool +) -> discord.SelectOption: + emojis = Emojis(interaction.client.emojis) + return discord.SelectOption( + label=pool.symbol, value=pool.lp, emoji=emojis.get("pool", "🏊‍♀️") + ) class _PoolsDropdown(discord.ui.Select): def __init__( self, + interaction: discord.Interaction, pools: List[LiquidityPool], - callback: Callable[[discord.InteractionResponse, str], Awaitable[None]], + callback: Callable[[discord.Interaction, str], Awaitable[None]], ): - options = list(map(build_select_option, pools)) + options = list(map(lambda p: build_select_option(interaction, p), pools)) super().__init__( placeholder="Which pool are you intersted in...", min_values=1, @@ -26,10 +33,23 @@ def __init__( self._callback = callback async def callback(self, interaction: discord.Interaction): - await self._callback(interaction.response, self.values[0]) + await self._callback(interaction, self.values[0]) class PoolsDropdown(discord.ui.View): - def __init__(self, pools: List[LiquidityPool], callback): + """Pool dropdown UI to present available pools to Discord users""" + + def __init__( + self, interaction: discord.Interaction, pools: List[LiquidityPool], callback + ): + """Builds a pool dropdown + + Args: + interaction (discord.Interaction): interaction object from the chat + pools (List[LiquidityPool]): pools to display + callback (function): callback for when a pool is selected + """ super().__init__() - self.add_item(_PoolsDropdown(pools=pools, callback=callback)) + self.add_item( + _PoolsDropdown(interaction=interaction, pools=pools, callback=callback) + ) diff --git a/emojis/apr.png b/emojis/apr.png new file mode 100644 index 0000000..11c5c7c Binary files /dev/null and b/emojis/apr.png differ diff --git a/emojis/coinplaceholder.png b/emojis/coinplaceholder.png new file mode 100644 index 0000000..081944d Binary files /dev/null and b/emojis/coinplaceholder.png differ diff --git a/emojis/dashgrey.png b/emojis/dashgrey.png new file mode 100644 index 0000000..b63d421 Binary files /dev/null and b/emojis/dashgrey.png differ diff --git a/emojis/dashwhite.png b/emojis/dashwhite.png new file mode 100644 index 0000000..9f0f733 Binary files /dev/null and b/emojis/dashwhite.png differ diff --git a/emojis/deposit.png b/emojis/deposit.png new file mode 100644 index 0000000..f003bd7 Binary files /dev/null and b/emojis/deposit.png differ diff --git a/emojis/emissions.png b/emojis/emissions.png new file mode 100644 index 0000000..cd8b823 Binary files /dev/null and b/emojis/emissions.png differ diff --git a/emojis/incentives.png b/emojis/incentives.png new file mode 100644 index 0000000..a5654f0 Binary files /dev/null and b/emojis/incentives.png differ diff --git a/emojis/pool.png b/emojis/pool.png new file mode 100644 index 0000000..a2b1c27 Binary files /dev/null and b/emojis/pool.png differ diff --git a/emojis/space.png b/emojis/space.png new file mode 100644 index 0000000..9947dc5 Binary files /dev/null and b/emojis/space.png differ diff --git a/emojis/volume.png b/emojis/volume.png new file mode 100644 index 0000000..8b105cf Binary files /dev/null and b/emojis/volume.png differ