224 lines
10 KiB
Python
224 lines
10 KiB
Python
# # ⚠ Warning
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
# LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
|
# NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
#
|
|
# [🥭 Mango Markets](https://mango.markets/) support is available at:
|
|
# [Docs](https://docs.mango.markets/)
|
|
# [Discord](https://discord.gg/67jySBhxrg)
|
|
# [Twitter](https://twitter.com/mangomarkets)
|
|
# [Github](https://github.com/blockworks-foundation)
|
|
# [Email](mailto:hello@blockworks.foundation)
|
|
|
|
import construct
|
|
import time
|
|
import typing
|
|
|
|
from decimal import Decimal
|
|
from solana.publickey import PublicKey
|
|
|
|
from .accountinfo import AccountInfo
|
|
from .addressableaccount import AddressableAccount
|
|
from .aggregator import Aggregator
|
|
from .baskettoken import BasketToken
|
|
from .context import Context
|
|
from .index import Index
|
|
from .layouts import layouts
|
|
from .mangoaccountflags import MangoAccountFlags
|
|
from .marketmetadata import MarketMetadata
|
|
from .market import MarketLookup
|
|
from .token import SolToken, Token, TokenLookup
|
|
from .tokenvalue import TokenValue
|
|
from .version import Version
|
|
|
|
# # 🥭 Group class
|
|
#
|
|
# The `Group` class encapsulates the data for the Mango Group - the cross-margined basket
|
|
# of tokens with lending.
|
|
|
|
|
|
class Group(AddressableAccount):
|
|
def __init__(self, account_info: AccountInfo, version: Version, name: str,
|
|
account_flags: MangoAccountFlags, basket_tokens: typing.List[BasketToken],
|
|
markets: typing.List[MarketMetadata],
|
|
signer_nonce: Decimal, signer_key: PublicKey, dex_program_id: PublicKey,
|
|
total_deposits: typing.List[TokenValue], total_borrows: typing.List[TokenValue],
|
|
maint_coll_ratio: Decimal, init_coll_ratio: Decimal, srm_vault: PublicKey,
|
|
admin: PublicKey, borrow_limits: typing.List[TokenValue]):
|
|
super().__init__(account_info)
|
|
self.version: Version = version
|
|
self.name: str = name
|
|
self.account_flags: MangoAccountFlags = account_flags
|
|
self.basket_tokens: typing.List[BasketToken] = basket_tokens
|
|
self.markets: typing.List[MarketMetadata] = markets
|
|
self.signer_nonce: Decimal = signer_nonce
|
|
self.signer_key: PublicKey = signer_key
|
|
self.dex_program_id: PublicKey = dex_program_id
|
|
self.total_deposits: typing.List[TokenValue] = total_deposits
|
|
self.total_borrows: typing.List[TokenValue] = total_borrows
|
|
self.maint_coll_ratio: Decimal = maint_coll_ratio
|
|
self.init_coll_ratio: Decimal = init_coll_ratio
|
|
self.srm_vault: PublicKey = srm_vault
|
|
self.admin: PublicKey = admin
|
|
self.borrow_limits: typing.List[TokenValue] = borrow_limits
|
|
|
|
@property
|
|
def shared_quote_token(self) -> BasketToken:
|
|
return self.basket_tokens[-1]
|
|
|
|
@property
|
|
def base_tokens(self) -> typing.List[BasketToken]:
|
|
return self.basket_tokens[:-1]
|
|
|
|
# When loading from a layout, we ignore mint_decimals. In this Discord from Daffy:
|
|
# https://discord.com/channels/791995070613159966/818978757648842782/851481660049850388
|
|
# he says it's:
|
|
# > same as what is stored on on chain Mint
|
|
# > Cached on the group so we don't have to pass in the mint every time
|
|
#
|
|
# Since we already have that value from our `Token` we don't need to use it and we can
|
|
# stick with passing around `Token` objects.
|
|
#
|
|
@staticmethod
|
|
def from_layout(layout: construct.Struct, name: str, account_info: AccountInfo, version: Version, token_lookup: TokenLookup, market_lookup: MarketLookup) -> "Group":
|
|
account_flags: MangoAccountFlags = MangoAccountFlags.from_layout(layout.account_flags)
|
|
|
|
basket_tokens: typing.List[BasketToken] = []
|
|
total_deposits: typing.List[TokenValue] = []
|
|
total_borrows: typing.List[TokenValue] = []
|
|
borrow_limits: typing.List[TokenValue] = []
|
|
for index, token_address in enumerate(layout.tokens):
|
|
static_token_data = token_lookup.find_by_mint(token_address)
|
|
if static_token_data is None:
|
|
raise Exception(f"Could not find token with mint '{token_address}'.")
|
|
|
|
# We create a new Token object here specifically to force the use of our own decimals
|
|
token = Token(static_token_data.symbol, static_token_data.name, token_address, layout.mint_decimals[index])
|
|
token_index = Index.from_layout(layout.indexes[index], token)
|
|
basket_token = BasketToken(token, layout.vaults[index], token_index)
|
|
basket_tokens += [basket_token]
|
|
total_deposits += [TokenValue(token, token.shift_to_decimals(layout.total_deposits[index]))]
|
|
total_borrows += [TokenValue(token, token.shift_to_decimals(layout.total_borrows[index]))]
|
|
borrow_limits += [TokenValue(token, token.shift_to_decimals(layout.borrow_limits[index]))]
|
|
|
|
markets: typing.List[MarketMetadata] = []
|
|
for index, market_address in enumerate(layout.spot_markets):
|
|
spot_market = market_lookup.find_by_address(market_address)
|
|
if spot_market is None:
|
|
raise Exception(f"Could not find spot market with address '{market_address}'.")
|
|
|
|
base_token = BasketToken.find_by_mint(basket_tokens, spot_market.base.mint)
|
|
quote_token = BasketToken.find_by_mint(basket_tokens, spot_market.quote.mint)
|
|
|
|
market = MarketMetadata(spot_market.symbol, market_address, base_token, quote_token,
|
|
spot_market, layout.oracles[index], layout.oracle_decimals[index])
|
|
markets += [market]
|
|
|
|
maint_coll_ratio = layout.maint_coll_ratio.quantize(Decimal('.01'))
|
|
init_coll_ratio = layout.init_coll_ratio.quantize(Decimal('.01'))
|
|
|
|
return Group(account_info, version, name, account_flags, basket_tokens, markets,
|
|
layout.signer_nonce, layout.signer_key, layout.dex_program_id, total_deposits,
|
|
total_borrows, maint_coll_ratio, init_coll_ratio, layout.srm_vault,
|
|
layout.admin, borrow_limits)
|
|
|
|
@staticmethod
|
|
def parse(context: Context, account_info: AccountInfo) -> "Group":
|
|
data = account_info.data
|
|
if len(data) == layouts.GROUP_V1.sizeof():
|
|
layout = layouts.GROUP_V1.parse(data)
|
|
version: Version = Version.V1
|
|
elif len(data) == layouts.GROUP_V2.sizeof():
|
|
version = Version.V2
|
|
layout = layouts.GROUP_V2.parse(data)
|
|
else:
|
|
raise Exception(
|
|
f"Group data length ({len(data)}) does not match expected size ({layouts.GROUP_V1.sizeof()} or {layouts.GROUP_V2.sizeof()})")
|
|
|
|
return Group.from_layout(layout, context.group_name, account_info, version, context.token_lookup, context.market_lookup)
|
|
|
|
@staticmethod
|
|
def load(context: Context):
|
|
account_info = AccountInfo.load(context, context.group_id)
|
|
if account_info is None:
|
|
raise Exception(f"Group account not found at address '{context.group_id}'")
|
|
return Group.parse(context, account_info)
|
|
|
|
def price_index_of_token(self, token: Token) -> int:
|
|
for index, existing in enumerate(self.basket_tokens):
|
|
if existing.token == token:
|
|
return index
|
|
return -1
|
|
|
|
def fetch_token_prices(self, context: Context) -> typing.List[TokenValue]:
|
|
started_at = time.time()
|
|
|
|
# Note: we can just load the oracle data in a simpler way, with:
|
|
# oracles = map(lambda market: Aggregator.load(context, market.oracle), self.markets)
|
|
# but that makes a network request for every oracle. We can reduce that to just one request
|
|
# if we use AccountInfo.load_multiple() and parse the data ourselves.
|
|
#
|
|
# This seems to halve the time this function takes.
|
|
oracle_addresses = list([market.oracle for market in self.markets])
|
|
oracle_account_infos = AccountInfo.load_multiple(context, oracle_addresses)
|
|
oracles = map(lambda oracle_account_info: Aggregator.parse(context, oracle_account_info), oracle_account_infos)
|
|
prices = list(map(lambda oracle: oracle.price, oracles)) + [Decimal(1)]
|
|
token_prices = []
|
|
for index, price in enumerate(prices):
|
|
token_prices += [TokenValue(self.basket_tokens[index].token, price)]
|
|
|
|
time_taken = time.time() - started_at
|
|
self.logger.info(f"Fetching prices complete. Time taken: {time_taken:.2f} seconds.")
|
|
return token_prices
|
|
|
|
@staticmethod
|
|
def load_with_prices(context: Context) -> typing.Tuple["Group", typing.List[TokenValue]]:
|
|
group = Group.load(context)
|
|
prices = group.fetch_token_prices(context)
|
|
return group, prices
|
|
|
|
def fetch_balances(self, context: Context, root_address: PublicKey) -> typing.List[TokenValue]:
|
|
balances: typing.List[TokenValue] = []
|
|
sol_balance = context.fetch_sol_balance(root_address)
|
|
balances += [TokenValue(SolToken, sol_balance)]
|
|
|
|
for basket_token in self.basket_tokens:
|
|
balance = TokenValue.fetch_total_value(context, root_address, basket_token.token)
|
|
balances += [balance]
|
|
return balances
|
|
|
|
def __str__(self) -> str:
|
|
total_deposits = "\n ".join(map(str, self.total_deposits))
|
|
total_borrows = "\n ".join(map(str, self.total_borrows))
|
|
borrow_limits = "\n ".join(map(str, self.borrow_limits))
|
|
shared_quote_token = str(self.shared_quote_token).replace("\n", "\n ")
|
|
base_tokens = "\n ".join([f"{tok}".replace("\n", "\n ") for tok in self.base_tokens])
|
|
markets = "\n ".join([f"{mkt}".replace("\n", "\n ") for mkt in self.markets])
|
|
return f"""
|
|
« Group [{self.version} - {self.name}] {self.address}:
|
|
Flags: {self.account_flags}
|
|
Base Tokens:
|
|
{base_tokens}
|
|
Quote Token:
|
|
{shared_quote_token}
|
|
Markets:
|
|
{markets}
|
|
DEX Program ID: « {self.dex_program_id} »
|
|
SRM Vault: « {self.srm_vault} »
|
|
Admin: « {self.admin} »
|
|
Signer Nonce: {self.signer_nonce}
|
|
Signer Key: « {self.signer_key} »
|
|
Initial Collateral Ratio: {self.init_coll_ratio}
|
|
Maintenance Collateral Ratio: {self.maint_coll_ratio}
|
|
Total Deposits:
|
|
{total_deposits}
|
|
Total Borrows:
|
|
{total_borrows}
|
|
Borrow Limits:
|
|
{borrow_limits}
|
|
»
|
|
"""
|