diff --git a/README.md b/README.md index b20d680..58576ad 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ pytest -v tests/test_overnight_max_leverage.py pytest -v tests/test_slippage.py pytest -v tests/test_target_funding_rate.py +pytest -v tests/test_vault.py + diff --git a/examples/vault_view_functions.py b/examples/vault_view_functions.py new file mode 100644 index 0000000..3d781d1 --- /dev/null +++ b/examples/vault_view_functions.py @@ -0,0 +1,115 @@ +from ostium_python_sdk import OstiumVault +from web3 import Web3 +import os +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Initialize Web3 and vault +w3 = Web3(Web3.HTTPProvider(os.getenv('ARBITRUM_RPC_URL'))) +vault = OstiumVault( + w3=w3, + vault_address=os.getenv('VAULT_ADDRESS'), + usdc_address=os.getenv('USDC_ADDRESS'), + private_key=os.getenv('PRIVATE_KEY'), + verbose=True +) + +def print_vault_info(): + # Basic token information + asset = vault.get_asset() + print(f"OLP Token Address: {asset}") + + name = vault.get_name() + print(f"Token Name: {name}") + + symbol = vault.get_symbol() + print(f"Token Symbol: {symbol}") + + decimals = vault.get_decimals() + print(f"Token Decimals: {decimals}") + + # Balance and supply information + balance = vault.get_balance() + print(f"My OLP Balance: {balance}") + + total_assets = vault.get_total_assets() + print(f"Total Assets in Vault: {total_assets} USDC") + + available_assets = vault.get_available_assets() + print(f"Available Assets: {available_assets} USDC") + + tvl = vault.get_tvl() + print(f"Total Value Locked: {tvl} USDC") + + market_cap = vault.get_market_cap() + print(f"Market Cap: {market_cap}") + + # PnL related information + acc_pnl = vault.get_acc_pnl_per_token() + print(f"Accumulated PnL per Token: {acc_pnl}") + + current_epoch_pnl = vault.get_current_epoch_positive_open_pnl() + print(f"Current Epoch Positive Open PnL: {current_epoch_pnl}") + + daily_pnl_delta = vault.get_daily_acc_pnl_delta_per_token() + print(f"Daily Accumulated PnL Delta per Token: {daily_pnl_delta}") + + total_closed_pnl = vault.get_total_closed_pnl() + print(f"Total Closed PnL: {total_closed_pnl}") + + # Epoch information + current_epoch = vault.get_current_epoch() + print(f"Current Epoch: {current_epoch}") + + epoch_start = vault.get_current_epoch_start() + print(f"Current Epoch Start: {epoch_start}") + + withdraw_timelock = vault.get_withdraw_epochs_timelock() + print(f"Withdrawal Epochs Timelock: {withdraw_timelock}") + + # Supply and limits + max_supply = vault.get_current_max_supply() + print(f"Current Max Supply: {max_supply}") + + max_supply_increase = vault.get_max_supply_increase_daily_p() + print(f"Max Supply Increase Daily %: {max_supply_increase}") + + # Discount information + max_discount = vault.get_max_discount_p() + print(f"Max Discount %: {max_discount}") + + max_discount_threshold = vault.get_max_discount_threshold_p() + print(f"Max Discount Threshold %: {max_discount_threshold}") + + total_discounts = vault.get_total_discounts() + print(f"Total Discounts: {total_discounts}") + + total_locked_discounts = vault.get_total_locked_discounts() + print(f"Total Locked Discounts: {total_locked_discounts}") + + # Other metrics + collateralization = vault.get_collateralization_p() + print(f"Collateralization %: {collateralization}") + + total_deposited = vault.get_total_deposited() + print(f"Total Deposited: {total_deposited} USDC") + + total_liability = vault.get_total_liability() + print(f"Total Liability: {total_liability}") + + total_rewards = vault.get_total_rewards() + print(f"Total Rewards: {total_rewards}") + + # Conversion examples + assets = 1000 # 1000 USDC + shares = vault.convert_to_shares(assets) + print(f"\nConversion Examples:") + print(f"{assets} USDC = {shares} shares") + + converted_assets = vault.convert_to_assets(shares) + print(f"{shares} shares = {converted_assets} USDC") + +if __name__ == "__main__": + print_vault_info() \ No newline at end of file diff --git a/ostium_python_sdk/__init__.py b/ostium_python_sdk/__init__.py index 1f37621..18ccd3d 100644 --- a/ostium_python_sdk/__init__.py +++ b/ostium_python_sdk/__init__.py @@ -2,5 +2,8 @@ from .subgraph import SubgraphClient from .config import NetworkConfig from .faucet import Faucet +from .ostium import Ostium +from .vault import OstiumVault -__all__ = ["OstiumSDK", "SubgraphClient", "NetworkConfig", "Faucet"] +__all__ = ["OstiumSDK", "SubgraphClient", + "NetworkConfig", "Faucet", "Ostium", "OstiumVault"] diff --git a/ostium_python_sdk/config.py b/ostium_python_sdk/config.py index 279c8d3..dbf4dea 100644 --- a/ostium_python_sdk/config.py +++ b/ostium_python_sdk/config.py @@ -21,7 +21,8 @@ def mainnet(cls) -> 'NetworkConfig': contracts={ "usdc": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", "trading": "0x6D0bA1f9996DBD8885827e1b2e8f6593e7702411", - "tradingStorage": "0xcCd5891083A8acD2074690F65d3024E7D13d66E7" + "tradingStorage": "0xcCd5891083A8acD2074690F65d3024E7D13d66E7", + "vault": "0x20D419a8e12C45f88fDA7c5760bb6923Cee27F98" }, is_testnet=False ) @@ -33,7 +34,8 @@ def testnet(cls) -> 'NetworkConfig': contracts={ "usdc": "0xe73B11Fb1e3eeEe8AF2a23079A4410Fe1B370548", "trading": "0x2A9B9c988393f46a2537B0ff11E98c2C15a95afe", - "tradingStorage": "0x0b9F5243B29938668c9Cfbd7557A389EC7Ef88b8" + "tradingStorage": "0x0b9F5243B29938668c9Cfbd7557A389EC7Ef88b8", + "vault": "0x2fbf52c8769c5da05afee7853b12775461cD04d2" }, is_testnet=True ) diff --git a/ostium_python_sdk/faucet.py b/ostium_python_sdk/faucet.py index a4c8039..b19225e 100644 --- a/ostium_python_sdk/faucet.py +++ b/ostium_python_sdk/faucet.py @@ -3,6 +3,7 @@ import time from datetime import datetime from ostium_python_sdk.abi.faucet_testnet_abi import faucet_abi # Import the ABI +from .utils import get_account # Class for testnet usage only - to get testnet USDC tokens @@ -50,12 +51,8 @@ def _check_private_key(self): raise ValueError( "Private key is required for Faucet operations") - def _get_account(self): - self._check_private_key() - return self.web3.eth.account.from_key(self.private_key) - def request_tokens(self) -> dict: - account = self._get_account() + account = get_account(self.web3, self.private_key) self.log("Requesting tokens from faucet") """ Request testnet USDC tokens from the faucet. diff --git a/ostium_python_sdk/ostium.py b/ostium_python_sdk/ostium.py index 78ade3e..80efa61 100644 --- a/ostium_python_sdk/ostium.py +++ b/ostium_python_sdk/ostium.py @@ -7,7 +7,7 @@ from .abi.usdc_abi import usdc_abi from .abi.trading_abi import trading_abi from .abi.trading_storage_abi import trading_storage_abi -from .utils import convert_to_scaled_integer, fromErrorCodeToMessage, get_tp_sl_prices, to_base_units +from .utils import convert_to_scaled_integer, fromErrorCodeToMessage, get_tp_sl_prices, to_base_units, approve_usdc, get_account from eth_account.account import Account @@ -75,14 +75,9 @@ def get_slippage_percentage(self): return self.slippage_percentage def get_public_address(self): - public_address = self._get_account().address + public_address = get_account(self.web3, self.private_key).address return public_address - def _get_account(self) -> Account: - self._check_private_key() - """Get account from stored private key""" - return self.web3.eth.account.from_key(self.private_key) - def get_block_number(self): return self.web3.eth.get_block('latest')['number'] @@ -96,10 +91,18 @@ def _check_private_key(self): def perform_trade(self, trade_params, at_price): self.log(f"Performing trade with params: {trade_params}") - account = self._get_account() + account = get_account(self.web3, self.private_key) amount = to_base_units(trade_params['collateral'], decimals=6) - self.__approve(account, amount, self.use_delegation, - trade_params.get('trader_address')) + + # Use shared approval function + approve_usdc( + self.web3, + self.usdc_contract, + self.ostium_trading_address, + amount, + self.private_key, + self.verbose + ) try: self.log(f"Final trade parameters being sent: {trade_params}") @@ -255,7 +258,7 @@ def close_trade(self, pair_id, trade_index, close_percentage=100, trader_address A dictionary containing the transaction receipt and order ID """ self.log(f"Closing trade for pair {pair_id}, index {trade_index}") - account = self._get_account() + account = get_account(self.web3, self.private_key) close_percentage = to_base_units(close_percentage, decimals=2) @@ -317,7 +320,7 @@ def close_trade(self, pair_id, trade_index, close_percentage=100, trader_address def remove_collateral(self, pair_id, trade_index, remove_amount): self.log( f"Remove collateral for trade for pair {pair_id}, index {trade_index}: {remove_amount} USDC") - account = self._get_account() + account = get_account(self.web3, self.private_key) amount = to_base_units(remove_amount, decimals=6) @@ -349,7 +352,7 @@ def add_collateral(self, pairID, index, collateral, trader_address=None): Returns: The transaction receipt """ - account = self._get_account() + account = get_account(self.web3, self.private_key) try: amount = to_base_units(collateral, decimals=6) self.__approve(account, amount, @@ -412,7 +415,7 @@ def update_tp(self, pair_id, trade_index, tp_price, trader_address=None): """ self.log( f"Updating TP for pair {pair_id}, index {trade_index} to {tp_price}") - account = self._get_account() + account = get_account(self.web3, self.private_key) try: tp_value = to_base_units(tp_price, decimals=18) @@ -470,7 +473,7 @@ def update_sl(self, pairID, index, sl, trader_address=None): Returns: The transaction receipt """ - account = self._get_account() + account = get_account(self.web3, self.private_key) try: sl_value = to_base_units(sl, decimals=18) @@ -546,7 +549,7 @@ def __approve(self, account, collateral, use_delegation, trader_address=None): f"Sufficient allowance for {trader_address} not present. Please approve the trading contract to spend USDC.") def withdraw(self, amount, receiving_address): - account = self._get_account() + account = get_account(self.web3, self.private_key) try: amount_in_base_units = to_base_units(amount, decimals=6) diff --git a/ostium_python_sdk/sdk.py b/ostium_python_sdk/sdk.py index b5ab16f..ef44b5a 100644 --- a/ostium_python_sdk/sdk.py +++ b/ostium_python_sdk/sdk.py @@ -14,6 +14,7 @@ from .price import Price from web3 import Web3 from .ostium import Ostium +from .vault import OstiumVault from .config import NetworkConfig from typing import Union from .subgraph import SubgraphClient @@ -75,6 +76,15 @@ def __init__(self, network: Union[str, NetworkConfig], private_key: str = None, use_delegation=self.use_delegation ) + # Initialize vault instance + self.vault = OstiumVault( + self.w3, + self.network_config.contracts["vault"], + self.network_config.contracts["usdc"], + private_key=self.private_key, + verbose=self.verbose + ) + # Initialize subgraph client self.subgraph = SubgraphClient( url=self.network_config.graph_url, verbose=self.verbose) diff --git a/ostium_python_sdk/utils.py b/ostium_python_sdk/utils.py index 110b9d5..c8c096c 100644 --- a/ostium_python_sdk/utils.py +++ b/ostium_python_sdk/utils.py @@ -2,6 +2,7 @@ from decimal import Decimal from web3 import Web3 from ast import literal_eval +from eth_account.account import Account from .constants import MAX_PROFIT_P, MAX_STOP_LOSS_P @@ -141,15 +142,33 @@ def fromErrorCodeToMessage(error_code, verbose=False): "35fe85c5": "WrongLeverage(uint32)", "5863f789": "WrongParams()", "083fbd78": "WrongSL()", - "a41bb918": "WrongTP()" + "a41bb918": "WrongTP()", + "6bcd09d8": "PendingWithdrawal(address,uint256)", + "f0f4c3e2": "AboveBalance()", + "d2eecb0b": "AboveMaxDeposit()", + "a0c0c8a1": "AboveMaxMint()", + "b1f8100d": "AboveWithdrawAmount()", + "e1c7392a": "DepositNotUnlocked(uint256)", + "4f1ef286": "NotEnoughAssets()", + "f7b7c3c6": "WaitNextEpochStart()", + "a6f9dae1": "NullAmount()", + "b1f8100d": "NullPrice()", + "d2eecb0b": "MaxDailyPnlReached()", + "a0c0c8a1": "NoActiveDiscount()", + "f0f4c3e2": "NoDiscount()", + "0efd6122": "WaitNextEpochStart()", + "3d093e84": "ERC20InsufficientAllowance(address,uint256,uint256)", + "4a67a8d2": "MathOverflowedMulDiv()" } # Search for any of the known error hashes within the error_code string for hash_code, error_message in error_map.items(): + # if verbose: + # print('checking', hash_code, 'in', str(error_code)) if hash_code in str(error_code): ret = error_message - if verbose: - print('----->fromErrorCodeToMessage(error_code) returns', ret) + # if verbose: + # print('FOUND THE ERROR', ret) return str(ret), None # If we couldn't find the error in error_map, try to parse the error @@ -218,4 +237,63 @@ def convert_decimals(obj): return str(obj) # or float(obj) if you prefer return obj + +def approve_usdc(w3: Web3, usdc_contract, spender_address: str, amount: int, private_key: str, verbose: bool = False) -> dict: + """ + Approve USDC spending for any contract. + + Args: + w3: Web3 instance + usdc_contract: USDC contract instance + spender_address: Address of the contract to approve + amount: Amount to approve in base units + private_key: Private key for transaction signing + verbose: Whether to log detailed information + + Returns: + Transaction receipt + """ + if verbose: + print(f"Approving {amount} USDC for {spender_address}") + + account = Account.from_key(private_key) + + # Build and send approval transaction + tx = usdc_contract.functions.approve( + spender_address, + amount + ).build_transaction({ + 'from': account.address, + 'nonce': w3.eth.get_transaction_count(account.address) + }) + + signed_tx = w3.eth.account.sign_transaction(tx, private_key) + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + + if verbose: + print( + f"Approval successful! Transaction hash: {receipt['transactionHash'].hex()}") + + return receipt + + +def get_account(w3: Web3, private_key: str) -> Account: + """ + Get an Ethereum account from a private key. + + Args: + w3: Web3 instance + private_key: Private key for the account + + Returns: + Account instance + + Raises: + ValueError: If private key is not provided + """ + if not private_key: + raise ValueError("Private key is required for this operation") + return Account.from_key(private_key) + # timestamp is a string in seconds as returned from graph diff --git a/ostium_python_sdk/vault.py b/ostium_python_sdk/vault.py new file mode 100644 index 0000000..be8a647 --- /dev/null +++ b/ostium_python_sdk/vault.py @@ -0,0 +1,1089 @@ +from decimal import Decimal, ROUND_DOWN, ROUND_UP +from web3 import Web3 +from .abi.vault_abi import vault_abi +from .abi.usdc_abi import usdc_abi +from .utils import convert_to_scaled_integer, to_base_units, approve_usdc, get_account, fromErrorCodeToMessage +from eth_account.account import Account + +# Precision constants +# PRECISION_2 = Decimal(1e2) +QUANTIZATION_2 = Decimal('0.01') + +# PRECISION_6 = Decimal(1e6) +# QUANTIZATION_6 = Decimal('0.000001') + +# PRECISION_18 = Decimal(1e18) +# QUANTIZATION_18 = Decimal('0.000000000000000001') + +# Minimum lock duration in seconds (1 week) +MIN_LOCK_DURATION = 7 * 24 * 60 * 60 # 7 days in seconds + + +class OstiumVault: + """ + Client for interacting with the Ostium vault on the Arbitrum network. + + Supports vault operations like deposits, withdrawals, and managing locked positions. + This class is designed for market makers and liquidity providers who want to + interact with the vault directly. + + Args: + w3: Web3 instance connected to the Arbitrum network + vault_address: Contract address for the Ostium vault + usdc_address: Contract address for USDC token + private_key: Optional private key for transaction signing. If not provided, + only read-only operations will be available. + verbose: Whether to log detailed information + """ + + def __init__(self, w3: Web3, vault_address: str, usdc_address: str, private_key: str = None, verbose=False) -> None: + self.web3 = w3 + self.verbose = verbose + self.private_key = private_key + self.vault_address = vault_address + self.usdc_address = usdc_address + + # Create contract instances + self.vault_contract = self.web3.eth.contract( + address=self.vault_address, abi=vault_abi) + self.usdc_contract = self.web3.eth.contract( + address=self.usdc_address, abi=usdc_abi) + + # Get deposit asset decimals (USDC) + self._deposit_asset_decimals = self.usdc_contract.functions.decimals().call() + + if (verbose): + print(f"Vault contract: {self.vault_contract.address}") + print(f"USDC contract: {self.usdc_contract.address}") + + def log(self, message): + if self.verbose: + print(message) + + def get_nonce(self, address): + return self.web3.eth.get_transaction_count(address) + + def deposit(self, amount: float, receiver: str = None): + """ + Deposit USDC into the vault. + + Args: + amount: Amount of USDC to deposit + receiver: Optional address to receive the vault shares (defaults to sender) + + Returns: + Transaction receipt + + Raises: + ValueError: If no private key is provided during initialization + """ + account = get_account(self.web3, self.private_key) + receiver = receiver or account.address + + # Convert amount to base units (6 decimals for USDC) + amount_base = to_base_units(amount, decimals=self._deposit_asset_decimals) + + # First approve the vault to spend USDC + self.log("Approving USDC spend for vault...") + approve_usdc( + self.web3, + self.usdc_contract, + self.vault_address, + amount_base, + self.private_key, + self.verbose + ) + + try: + # Build and send deposit transaction + self.log("Depositing USDC to vault...") + tx = self.vault_contract.functions.deposit( + amount_base, + receiver + ).build_transaction({ + 'from': account.address, + 'nonce': self.get_nonce(account.address) + }) + + signed_tx = self.web3.eth.account.sign_transaction( + tx, self.private_key) + tx_hash = self.web3.eth.send_raw_transaction( + signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) + + return receipt + except Exception as e: + reason_string, suggestion = fromErrorCodeToMessage( + str(e), verbose=self.verbose) + print( + f"An error occurred during the deposit process: {reason_string}") + raise Exception( + f'{reason_string}\n\n{suggestion}' if suggestion != None else reason_string) + + def deposit_with_lock(self, amount: float, lock_period_seconds: int, receiver: str = None): + """ + Deposit USDC into the vault with a lock period. + + Args: + amount: Amount of USDC to deposit + lock_period_seconds: Lock period in seconds (minimum 1 week) + receiver: Optional address to receive the vault shares (defaults to sender) + + Returns: + Transaction receipt + + Raises: + ValueError: If no private key is provided during initialization or if lock period is less than 1 week + """ + if lock_period_seconds < MIN_LOCK_DURATION: + raise ValueError( + f"Lock period must be at least {MIN_LOCK_DURATION} seconds (1 week)") + + account = get_account(self.web3, self.private_key) + receiver = receiver or account.address + + # Convert amount to base units (6 decimals for USDC) + amount_base = to_base_units(amount, decimals=self._deposit_asset_decimals) + + # First approve the vault to spend USDC + self.log("Approving USDC spend for vault...") + approve_usdc( + self.web3, + self.usdc_contract, + self.vault_address, + amount_base, + self.private_key, + self.verbose + ) + + try: + # Build and send transaction + self.log("Depositing USDC to vault with lock...") + tx = self.vault_contract.functions.depositWithDiscountAndLock( + amount_base, + lock_period_seconds, + receiver + ).build_transaction({ + 'from': account.address, + 'nonce': self.get_nonce(account.address) + }) + + signed_tx = self.web3.eth.account.sign_transaction( + tx, self.private_key) + tx_hash = self.web3.eth.send_raw_transaction( + signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) + + return receipt + except Exception as e: + reason_string, suggestion = fromErrorCodeToMessage( + str(e), verbose=self.verbose) + print( + f"An error occurred during the deposit with discount and lock process: {reason_string}") + raise Exception( + f'{reason_string}\n\n{suggestion}' if suggestion != None else reason_string) + + def withdraw(self, shares: float, receiver: str = None): + """ + Withdraw USDC from the vault by burning shares. + + Args: + shares: Amount of vault shares to burn + receiver: Optional address to receive the USDC (defaults to sender) + + Returns: + Transaction receipt + + Raises: + ValueError: If no private key is provided during initialization + """ + if shares > self.max_withdraw(): + raise ValueError( + f"Shares to withdraw ({shares}) exceeds maximum withdrawable amount ({self.max_withdraw()})") + + account = get_account(self.web3, self.private_key) + receiver = receiver or account.address + + # Convert shares to base units using vault decimals + shares_base = to_base_units(shares, decimals=self.get_decimals()) + + try: + # Build and send transaction + tx = self.vault_contract.functions.redeem( + shares_base, + receiver, + account.address + ).build_transaction({ + 'from': account.address, + 'nonce': self.get_nonce(account.address) + }) + + signed_tx = self.web3.eth.account.sign_transaction( + tx, self.private_key) + tx_hash = self.web3.eth.send_raw_transaction( + signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) + + return receipt + except Exception as e: + reason_string, suggestion = fromErrorCodeToMessage( + str(e), verbose=self.verbose) + print( + f"An error occurred during the withdraw process: {reason_string}") + raise Exception( + f'{reason_string}\n\n{suggestion}' if suggestion != None else reason_string) + + def get_balance(self, address: str = None): + """ + Get the balance of vault shares for an address. + + Args: + address: Address to check balance for. If None, uses the address from the private key. + + Returns: + Balance of vault shares + """ + if address is None: + if self.private_key is None: + raise ValueError( + "Either address parameter or private_key must be provided") + + balance = self.vault_contract.functions.balanceOf(address).call() + return Decimal(balance) / Decimal(10**self._deposit_asset_decimals) # Both shares and assets use same decimals + + def get_total_assets(self): + """ + Get the total assets in the vault. + + Returns: + Total assets in USDC + """ + total_assets = self.vault_contract.functions.totalAssets().call() + return Decimal(total_assets) / Decimal(10**self._deposit_asset_decimals) + + def get_total_supply(self) -> Decimal: + """ + Get the total supply of vault shares. + + Returns: + Total supply of vault shares + """ + total_supply = self.vault_contract.functions.totalSupply().call() + + if (self.verbose): + print(f"total_supply: {total_supply}") + + return Decimal(total_supply) / Decimal(10**self._deposit_asset_decimals) # Both shares and assets use same decimals + + def get_asset_per_share(self): + """ + Get the current asset per share ratio. + + Returns: + Asset per share ratio + """ + total_assets = self.get_total_assets() + total_supply = self.get_total_supply() + + if (self.verbose): + print(f"total_assets TVL???: {total_assets}") + print(f"total_supply OLP Supply: {total_supply}") + + if total_supply == 0: + return Decimal(1) + + return total_assets / total_supply + + def get_acc_pnl_per_token(self) -> Decimal: + """ + Get the accumulated PnL per token. + + Returns: + Accumulated PnL per token + """ + pnl = self.vault_contract.functions.accPnlPerToken().call() + return Decimal(pnl) / Decimal(10**self._deposit_asset_decimals) # Both shares and assets use same decimals + + def get_asset(self) -> str: + """ + Get the OLP token address. + + Returns: + OLP token address + """ + return self.vault_contract.functions.asset().call() + + def get_available_assets(self) -> Decimal: + """ + Get the available assets in the vault. + + Returns: + Available assets in USDC + """ + available = self.vault_contract.functions.availableAssets().call() + return Decimal(available) / Decimal(10**self._deposit_asset_decimals) # Deposit asset decimals + + def get_collateralization_p(self) -> Decimal: + """ + Get the collateralization percentage. + + Returns: + Collateralization percentage (e.g., 99.13 for 99.13%) + """ + collat = self.vault_contract.functions.collateralizationP().call() + return Decimal(collat) / Decimal(10**2) + + def convert_to_shares(self, assets: float) -> Decimal: + """ + Convert assets to shares. + + Args: + assets: Amount of assets in USDC + + Returns: + Amount of shares + """ + assets_base = to_base_units(assets, decimals=self._deposit_asset_decimals) # USDC decimals + shares = self.vault_contract.functions.convertToShares( + assets_base).call() + return Decimal(shares) / Decimal(10**self._deposit_asset_decimals) # Both shares and assets use same decimals + + def convert_to_assets(self, shares: float) -> Decimal: + """ + Convert shares to assets. + + Args: + shares: Amount of shares + + Returns: + Amount of assets in USDC + """ + shares_base = to_base_units(shares, decimals=self.get_decimals()) # Vault decimals + assets = self.vault_contract.functions.convertToAssets( + shares_base).call() + return Decimal(assets) / Decimal(10**self._deposit_asset_decimals) # Both shares and assets use same decimals + + def get_current_epoch(self) -> int: + """ + Get the current epoch number. + + Returns: + Current epoch number + """ + return self.vault_contract.functions.currentEpoch().call() + + def get_current_epoch_positive_open_pnl(self) -> Decimal: + """ + Get the current epoch's positive open PnL. + + Returns: + Current epoch's positive open PnL in USDC + """ + pnl = self.vault_contract.functions.currentEpochPositiveOpenPnl().call() + return Decimal(pnl) / Decimal(10**self._deposit_asset_decimals) # Deposit asset decimals + + def get_current_epoch_start(self) -> int: + """ + Get the timestamp when the current epoch started. + + Returns: + Current epoch start timestamp + """ + return self.vault_contract.functions.currentEpochStart().call() + + def get_current_max_supply(self) -> int: + """ + Get the current maximum supply. + + Returns: + Current maximum supply as an integer + """ + return self.vault_contract.functions.currentMaxSupply().call() + + def get_daily_acc_pnl_delta_per_token(self) -> Decimal: + """ + Get the daily accumulated PnL delta per token. + + Returns: + Daily accumulated PnL delta per token in USDC + """ + pnl = self.vault_contract.functions.dailyAccPnlDeltaPerToken().call() + return Decimal(pnl) / Decimal(10**self._deposit_asset_decimals) # Deposit asset decimals + + def get_market_cap(self) -> Decimal: + """ + Get the market cap of the vault. + + Returns: + Market cap in USDC + """ + market_cap = self.vault_contract.functions.marketCap().call() + return Decimal(market_cap) / Decimal(10**self._deposit_asset_decimals) # Deposit asset decimals + + def get_max_acc_open_pnl_delta_per_token(self) -> int: + """ + Get the maximum accumulated open PnL delta per token. + + Returns: + Maximum accumulated open PnL delta per token as an integer + """ + return self.vault_contract.functions.maxAccOpenPnlDeltaPerToken().call() + + def get_max_acc_pnl_per_token(self) -> int: + """ + Get the maximum accumulated PnL per token. + + Returns: + Maximum accumulated PnL per token as an integer + """ + return self.vault_contract.functions.maxAccPnlPerToken().call() + + def get_max_daily_acc_pnl_delta_per_token(self) -> int: + """ + Get the maximum daily accumulated PnL delta per token. + + Returns: + Maximum daily accumulated PnL delta per token as an integer + """ + return self.vault_contract.functions.maxDailyAccPnlDeltaPerToken().call() + + def get_max_discount_p(self) -> Decimal: + """ + Get the maximum discount percentage. + + Returns: + Maximum discount percentage (e.g., 50.00 for 50%) + """ + discount = self.vault_contract.functions.maxDiscountP().call() + return Decimal(discount) / Decimal(10**2) + + def get_max_discount_threshold_p(self) -> Decimal: + """ + Get the maximum discount threshold percentage. + + Returns: + Maximum discount threshold percentage (e.g., 120.00 for 120%) + """ + threshold = self.vault_contract.functions.maxDiscountThresholdP().call() + return Decimal(threshold) / Decimal(10**2) + + def get_max_supply_increase_daily_p(self) -> Decimal: + """ + Get the maximum supply increase daily percentage. + + Returns: + Maximum supply increase daily percentage (e.g., 300.00 for 300%) + """ + increase = self.vault_contract.functions.maxSupplyIncreaseDailyP().call() + return Decimal(increase) / Decimal(10**2) + + def get_name(self) -> str: + """ + Get the name of the vault token. + + Returns: + Token name + """ + return self.vault_contract.functions.name().call() + + def get_symbol(self) -> str: + """ + Get the symbol of the vault token. + + Returns: + Token symbol + """ + return self.vault_contract.functions.symbol().call() + + def get_decimals(self) -> int: + """ + Get the number of decimals for the vault token. + + Returns: + Number of decimals + """ + return self.vault_contract.functions.decimals().call() + + def get_total_closed_pnl(self) -> Decimal: + """ + Get the total closed PnL. + + Returns: + Total closed PnL in USDC + """ + pnl = self.vault_contract.functions.totalClosedPnl().call() + return Decimal(pnl) / Decimal(10**self._deposit_asset_decimals) # Deposit asset decimals + + def get_total_deposited(self) -> Decimal: + """ + Get the total amount deposited in the vault. + + Returns: + Total deposited amount in USDC + """ + total = self.vault_contract.functions.totalDeposited().call() + return Decimal(total) / Decimal(10**self._deposit_asset_decimals) # Deposit asset decimals + + def get_total_discounts(self) -> Decimal: + """ + Get the total discounts. + + Returns: + Total discounts in USDC + """ + discounts = self.vault_contract.functions.totalDiscounts().call() + return Decimal(discounts) / Decimal(10**self._deposit_asset_decimals) # Deposit asset decimals + + def get_total_liability(self) -> Decimal: + """ + Get the total liability. + + Returns: + Total liability in USDC + """ + liability = self.vault_contract.functions.totalLiability().call() + return Decimal(liability) / Decimal(10**self._deposit_asset_decimals) # Deposit asset decimals + + def get_total_locked_discounts(self) -> Decimal: + """ + Get the total locked discounts. + + Returns: + Total locked discounts in USDC + """ + discounts = self.vault_contract.functions.totalLockedDiscounts().call() + return Decimal(discounts) / Decimal(10**self._deposit_asset_decimals) # Deposit asset decimals + + def get_total_rewards(self) -> Decimal: + """ + Get the total rewards. + + Returns: + Total rewards in USDC + """ + rewards = self.vault_contract.functions.totalRewards().call() + return Decimal(rewards) / Decimal(10**self._deposit_asset_decimals) # Deposit asset decimals + + def get_tvl(self) -> Decimal: + """ + Get the total value locked in the vault. + + Returns: + Total value locked in USDC + """ + tvl = self.vault_contract.functions.tvl().call() + return Decimal(tvl) / Decimal(10**self._deposit_asset_decimals) # Deposit asset decimals + + def get_withdraw_epochs_timelock(self) -> int: + """ + Get the number of epochs required for withdrawal timelock. + + Returns: + Number of epochs for withdrawal timelock + """ + return self.vault_contract.functions.withdrawEpochsTimelock().call() + + def get_share_to_assets_price(self) -> Decimal: + """Get the share to assets price.""" + price = self.vault_contract.functions.shareToAssetsPrice().call() + return Decimal(price) / Decimal(10**self.get_decimals()) # Vault decimals + + def preview_deposit(self, assets: Decimal) -> Decimal: + """ + Preview how many shares would be received for depositing assets. + + Args: + assets: Amount of assets to deposit in USDC + + Returns: + Amount of shares that would be received + """ + try: + share_to_assets_price = self.get_share_to_assets_price() + if share_to_assets_price == Decimal(0): + return Decimal(0) + + # Check for potential overflow + if assets > Decimal(2**256 - 1) / Decimal(10**self._deposit_asset_decimals): + raise ValueError("Deposit amount too large, would cause overflow") + + return (assets / share_to_assets_price).quantize(Decimal('0.' + '0' * self.get_decimals()), rounding=ROUND_DOWN) + except Exception as e: + reason_string, suggestion = fromErrorCodeToMessage( + str(e), verbose=self.verbose) + print( + f"An error occurred during the preview deposit process: {reason_string}") + raise Exception( + f'{reason_string}\n\n{suggestion}' if suggestion != None else reason_string) + + def preview_mint(self, shares: Decimal) -> Decimal: + """ + Preview how many assets would be needed to mint shares. + + Args: + shares: Amount of shares to mint + + Returns: + Amount of assets needed in USDC + """ + share_to_assets_price = self.get_share_to_assets_price() + uint256_max = Decimal((2**256 - 1) / Decimal(10**self._deposit_asset_decimals)) + if shares == uint256_max and share_to_assets_price >= Decimal(1): + return shares + return (shares * share_to_assets_price).quantize(Decimal('0.' + '0' * self._deposit_asset_decimals), rounding=ROUND_UP) + + def preview_redeem(self, shares: Decimal) -> Decimal: + """ + Preview how many assets would be received for redeeming shares. + + Args: + shares: Amount of shares to redeem + + Returns: + Amount of assets that would be received in USDC + """ + return self.convert_to_assets(shares) + + def preview_withdraw(self, assets: Decimal) -> Decimal: + """ + Preview how many shares would be burned for withdrawing assets. + + Args: + assets: Amount of assets to withdraw in USDC + + Returns: + Amount of shares that would be burned + """ + return self.convert_to_shares(assets) + + def get_lock_discount_p(self, lock_duration_seconds: int) -> Decimal: + """ + Calculate the lock discount percentage based on lock duration and current collateralization. + + Args: + lock_duration: Lock duration in seconds + + Returns: + Lock discount percentage (e.g., 50.00 for 50%) + """ + max_discount_threshold_p = self.get_max_discount_threshold_p() + max_lock_duration = 365 * 24 * 60 * 60 # 1 year in seconds + max_discount_p = self.get_max_discount_p() + collateralization_p = self.get_collateralization_p() + + lock_discount_p = Decimal(0) + + if collateralization_p <= Decimal(100): + result = max_discount_p + elif collateralization_p <= max_discount_threshold_p: + numerator1 = max_discount_p * \ + (max_discount_threshold_p - collateralization_p) + denominator1 = (max_discount_threshold_p - Decimal(100)) + if denominator1 != Decimal(0): + result = ( + numerator1 / denominator1).quantize(QUANTIZATION_2, rounding=ROUND_DOWN) + else: + result = Decimal(0) + + lock_discount_p = (result * Decimal(lock_duration_seconds) / Decimal( + max_lock_duration)).quantize(QUANTIZATION_2, rounding=ROUND_DOWN) + + return lock_discount_p + + def preview_deposit_with_discount_and_lock(self, assets: Decimal, lock_duration_seconds: int) -> tuple[Decimal, Decimal, Decimal, Decimal]: + """ + Preview the result of depositing assets with discount and lock. + + Args: + assets: Amount of assets to deposit in USDC + lock_duration: Lock duration in seconds + + Returns: + Tuple of (shares, assets_deposited, assets_discount, lock_discount_p) + """ + lock_discount_p = self.get_lock_discount_p(lock_duration_seconds) + + simulated_assets = assets * \ + ((Decimal(100) + lock_discount_p) / Decimal(100) + ).quantize(QUANTIZATION_2, rounding=ROUND_DOWN) + shares = self.preview_deposit(simulated_assets) + assets_deposited = assets + assets_discount = simulated_assets - assets_deposited + + return shares, assets_deposited, assets_discount, lock_discount_p + + def preview_mint_with_discount_and_lock(self, shares: Decimal, lock_duration_seconds: int) -> tuple[Decimal, Decimal, Decimal]: + """ + Preview the result of minting shares with discount and lock. + + Args: + shares: Amount of shares to mint + lock_duration: Lock duration in seconds + + Returns: + Tuple of (shares, assets_deposited, assets_discount) + """ + lock_discount_p = self.get_lock_discount_p(lock_duration_seconds) + + assets = self.preview_mint(shares) + assets_deposited = (assets * (Decimal(100) / (Decimal(100) + + lock_discount_p))).quantize(Decimal('0.' + '0' * self._deposit_asset_decimals), rounding=ROUND_DOWN) + assets_discount = assets - assets_deposited + + return shares, assets_deposited, assets_discount + + def redeem(self, shares: float, receiver: str = None, owner: str = None): + """ + Redeem shares for assets. + + Args: + shares: Amount of shares to redeem + receiver: Optional address to receive the assets (defaults to sender) + owner: Optional address that owns the shares (defaults to sender) + + Returns: + Transaction receipt + + Raises: + ValueError: If no private key is provided during initialization + """ + if shares > self.max_redeem(): + raise ValueError( + f"Shares to redeem ({shares}) exceeds maximum redeemable amount ({self.max_redeem()})") + + account = get_account(self.web3, self.private_key) + receiver = receiver or account.address + owner = owner or account.address + + + shares_base = to_base_units(shares, decimals=self.get_decimals()) + + try: + # Build and send transaction + tx = self.vault_contract.functions.redeem( + shares_base, + receiver, + owner + ).build_transaction({ + 'from': account.address, + 'nonce': self.get_nonce(account.address) + }) + + signed_tx = self.web3.eth.account.sign_transaction( + tx, self.private_key) + tx_hash = self.web3.eth.send_raw_transaction( + signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) + + return receipt + except Exception as e: + reason_string, suggestion = fromErrorCodeToMessage( + str(e), verbose=self.verbose) + print( + f"An error occurred during the redeem process: {reason_string}") + raise Exception( + f'{reason_string}\n\n{suggestion}' if suggestion != None else reason_string) + + def max_withdraw(self, owner: str = None) -> Decimal: + """ + Get the maximum amount of assets that can be withdrawn by an owner. + + Args: + owner: Address to check max withdrawal for (defaults to sender if private key provided) + + Returns: + Maximum amount of assets that can be withdrawn in USDC + """ + if owner is None and self.private_key: + account = get_account(self.web3, self.private_key) + owner = account.address + elif owner is None: + raise ValueError( + "Either owner parameter or private_key must be provided") + + try: + max_assets = self.vault_contract.functions.maxWithdraw( + owner).call() + return Decimal(max_assets) / Decimal(10**self._deposit_asset_decimals) + except Exception as e: + reason_string, suggestion = fromErrorCodeToMessage( + str(e), verbose=self.verbose) + print( + f"An error occurred during the max withdraw process: {reason_string}") + raise Exception( + f'{reason_string}\n\n{suggestion}' if suggestion != None else reason_string) + + def max_mint(self, owner: str = None) -> Decimal: + """ + Get the maximum amount of shares that can be minted by an owner. + + Args: + owner: Address of the owner. If None, uses the address from the private key. + + Returns: + Maximum amount of shares that can be minted + """ + if owner is None: + if self.private_key is None: + raise ValueError( + "Either owner parameter or private_key must be provided") + owner = self.web3.eth.account.from_key(self.private_key).address + + max_shares = self.vault_contract.functions.maxMint(owner).call() + return Decimal(max_shares) / Decimal(10**self._deposit_asset_decimals) # Both shares and assets use same decimals + + def max_redeem(self, owner: str = None) -> Decimal: + """ + Get the maximum amount of shares that can be redeemed by an owner. + + Args: + owner: Address of the owner. If None, uses the address from the private key. + + Returns: + Maximum amount of shares that can be redeemed + """ + if owner is None: + if self.private_key is None: + raise ValueError( + "Either owner parameter or private_key must be provided") + owner = self.web3.eth.account.from_key(self.private_key).address + + max_shares = self.vault_contract.functions.maxRedeem(owner).call() + return Decimal(max_shares) / Decimal(10**self._deposit_asset_decimals) # Both shares and assets use same decimals + + def mint_with_discount_and_lock(self, shares: float, lock_duration_seconds: int, receiver: str = None): + """ + Mint shares with discount and lock period. + + Args: + shares: Amount of shares to mint + lock_duration_seconds: Lock period in seconds (minimum 1 week) + receiver: Optional address to receive the shares (defaults to sender) + + Returns: + Transaction receipt + + Raises: + ValueError: If no private key is provided during initialization or if lock period is less than 1 week + """ + if lock_duration_seconds < MIN_LOCK_DURATION: + raise ValueError( + f"Lock period must be at least {MIN_LOCK_DURATION} seconds (1 week)") + + account = get_account(self.web3, self.private_key) + receiver = receiver or account.address + + if (self.verbose): + print(f"shares: {shares}") + print(f"lock_duration_seconds: {lock_duration_seconds}") + print(f"receiver: {receiver}") + + + shares_base = to_base_units(shares, decimals=self.get_decimals()) + + if (self.verbose): + print(f"shares_base: {shares_base}") + + # Calculate required USDC amount + assets_needed = self.preview_mint(Decimal(shares)) + + if (self.verbose): + print(f"assets_needed: {assets_needed}") + + assets_base = to_base_units(assets_needed, decimals=self._deposit_asset_decimals) + + # First approve the vault to spend USDC + self.log( + f"Approving {assets_needed} = {assets_base} USDC spend for vault...") + approve_usdc( + self.web3, + self.usdc_contract, + self.vault_address, + assets_base, + self.private_key, + self.verbose + ) + + try: + # Build and send transaction + tx = self.vault_contract.functions.mintWithDiscountAndLock( + shares_base, + lock_duration_seconds, + receiver + ).build_transaction({ + 'from': account.address, + 'nonce': self.get_nonce(account.address) + }) + + signed_tx = self.web3.eth.account.sign_transaction( + tx, self.private_key) + tx_hash = self.web3.eth.send_raw_transaction( + signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) + + return receipt + except Exception as e: + reason_string, suggestion = fromErrorCodeToMessage( + str(e), verbose=self.verbose) + print( + f"An error occurred during the mint with discount and lock process: {reason_string}") + raise Exception( + f'{reason_string}\n\n{suggestion}' if suggestion != None else reason_string) + + def make_withdraw_request(self, shares: float, owner: str = None): + """ + Make a withdrawal request for the specified number of shares. + + Args: + shares: The number of shares to withdraw + owner: Optional address of the owner if different from the account (for delegation) + + Returns: + The transaction receipt + """ + + try: + account = get_account(self.web3, self.private_key) + owner = owner or account.address + + + shares_base = to_base_units(shares, decimals=self.get_decimals()) + + # Build and send transaction + tx = self.vault_contract.functions.makeWithdrawRequest( + shares_base, + owner + ).build_transaction({ + 'from': account.address, + 'nonce': self.get_nonce(account.address) + }) + + signed_tx = self.web3.eth.account.sign_transaction( + tx, self.private_key) + tx_hash = self.web3.eth.send_raw_transaction( + signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) + + return receipt + + except Exception as e: + reason_string, suggestion = fromErrorCodeToMessage( + str(e), verbose=self.verbose) + print( + f"An error occurred during the make withdraw request process: {reason_string}") + raise Exception( + f'{reason_string}\n\n{suggestion}' if suggestion != None else reason_string) + + def cancel_withdraw_request(self, shares: float, owner: str = None, unlock_epoch: int = None): + """ + Cancel a withdrawal request for shares. + + Args: + shares: Amount of shares to cancel withdrawal for + owner: Optional address that owns the shares (defaults to sender) + unlock_epoch: Optional epoch number when the withdrawal would unlock (defaults to current epoch + timelock) + + Returns: + Transaction receipt + + Raises: + ValueError: If no private key is provided during initialization + """ + + try: + account = get_account(self.web3, self.private_key) + owner = owner or account.address + + if unlock_epoch is None: + current_epoch = self.get_current_epoch() + timelock = self.get_withdraw_epochs_timelock() + unlock_epoch = current_epoch + timelock + + + shares_base = to_base_units(shares, decimals=self.get_decimals()) + + # Build and send transaction + tx = self.vault_contract.functions.cancelWithdrawRequest( + shares_base, + owner, + unlock_epoch + ).build_transaction({ + 'from': account.address, + 'nonce': self.get_nonce(account.address) + }) + + signed_tx = self.web3.eth.account.sign_transaction( + tx, self.private_key) + tx_hash = self.web3.eth.send_raw_transaction( + signed_tx.raw_transaction) + receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) + + return receipt + except Exception as e: + reason_string, suggestion = fromErrorCodeToMessage( + str(e), verbose=self.verbose) + print( + f"An error occurred during the make withdraw request process: {reason_string}") + raise Exception( + f'{reason_string}\n\n{suggestion}' if suggestion != None else reason_string) + + def get_acc_rewards_per_token(self) -> Decimal: + """Get the accumulated rewards per token.""" + rewards = self.vault_contract.functions.accRewardsPerToken().call() + return Decimal(rewards) / Decimal(10**self.get_decimals()) # Vault decimals + + def get_last_daily_acc_pnl_delta_reset_ts(self) -> int: + """Get the last daily accumulated PnL delta reset timestamp.""" + return self.vault_contract.functions.lastDailyAccPnlDeltaResetTs().call() + + def get_locked_deposits_count(self) -> int: + """ + Get the total number of locked deposits. + + Returns: + Total number of locked deposits + """ + return self.vault_contract.functions.lockedDepositsCount().call() + + def get_locked_deposit(self, deposit_id: int) -> dict: + """ + Get information about a specific locked deposit. + + Args: + deposit_id: ID of the locked deposit + + Returns: + Dictionary containing deposit information + """ + deposit = self.vault_contract.functions.lockedDeposits( + deposit_id).call() + return { + 'owner': deposit[0], + 'shares': Decimal(deposit[1]) / Decimal(10**self.get_decimals()), # Vault decimals + 'assetsDeposited': Decimal(deposit[2]) / Decimal(10**self._deposit_asset_decimals), # Deposit asset decimals + 'assetsDiscount': Decimal(deposit[3]) / Decimal(10**self._deposit_asset_decimals), # Deposit asset decimals + 'atTimestamp': deposit[4], + 'lockDuration': deposit[5] + } + + def locked_deposits(self, deposit_id: int) -> tuple: + """ + Get raw locked deposit information. + + Args: + deposit_id: ID of the locked deposit + + Returns: + Tuple containing raw deposit information + """ + return self.vault_contract.functions.lockedDeposits(deposit_id).call() + + def get_current_balance(self) -> Decimal: + """ + Get the current balance of the vault. + + Returns: + Current balance in USDC + """ + balance = self.vault_contract.functions.currentBalance().call() + return Decimal(balance) / Decimal(10**self._deposit_asset_decimals) # Deposit asset decimals diff --git a/tests/test_vault.py b/tests/test_vault.py new file mode 100644 index 0000000..e898b8f --- /dev/null +++ b/tests/test_vault.py @@ -0,0 +1,513 @@ +import os +import pytest +from decimal import Decimal +from dotenv import load_dotenv +from ostium_python_sdk import OstiumSDK +from ostium_python_sdk.config import NetworkConfig +from eth_account import Account +from web3 import Web3 + + +@pytest.fixture(scope="module") +def sdk(): + # Load environment variables + load_dotenv() + + rpc_url = os.getenv('RPC_URL') + if not rpc_url: + raise ValueError("RPC_URL not found in .env file") + + # Initialize SDK with testnet config + config = NetworkConfig.testnet() + return OstiumSDK(config) + + +@pytest.fixture(scope="module") +def account(): + if not os.getenv('PRIVATE_KEY'): + pytest.skip("PRIVATE_KEY not found in .env file") + return Account.from_key(os.getenv('PRIVATE_KEY')) + + +def test_convert_to_shares(sdk): + """Test converting assets to shares""" + # Test with 1000 USDC + assets = Decimal('1000.0') + shares = sdk.vault.convert_to_shares(assets) + + # Shares should be greater than 0 + assert shares > 0 + + # If TVL is available, we can make more specific assertions + tvl = sdk.vault.get_tvl() + if tvl > 0: + # Shares should be proportional to assets + total_supply = sdk.vault.get_total_supply() + expected_shares = (assets * total_supply) / tvl + # Allow for small rounding differences + assert abs(shares - expected_shares) < Decimal('0.01') + + +def test_convert_to_assets(sdk): + """Test converting shares to assets""" + # Test with 1000 shares + shares = Decimal('1000.0') + assets = sdk.vault.convert_to_assets(shares) + + # Assets should be greater than 0 + assert assets > 0 + + # If TVL is available, we can make more specific assertions + tvl = sdk.vault.get_tvl() + if tvl > 0: + # Assets should be proportional to shares + total_supply = sdk.vault.get_total_supply() + expected_assets = (shares * tvl) / total_supply + + # Calculate relative difference instead of absolute + relative_diff = abs(assets - expected_assets) / expected_assets + # Allow for 0.1% difference due to rounding + assert relative_diff < Decimal( + '0.001'), f"Relative difference {relative_diff} exceeds tolerance" + + +def test_convert_roundtrip(sdk): + """Test that converting assets to shares and back gives approximately the same amount""" + original_assets = Decimal('1000.0') + shares = sdk.vault.convert_to_shares(original_assets) + converted_assets = sdk.vault.convert_to_assets(shares) + + # The roundtrip conversion should be close to the original amount + # Allow for small rounding differences + assert abs(original_assets - converted_assets) < Decimal('0.01') + + +def test_vault_metrics(sdk): + """Test various vault metrics""" + # Test TVL + tvl = sdk.vault.get_tvl() + assert tvl >= 0 + + # Test total supply + total_supply = sdk.vault.get_total_supply() + assert total_supply >= 0 + + # Test total assets + total_assets = sdk.vault.get_total_assets() + assert total_assets >= 0 + + # Test available assets + available_assets = sdk.vault.get_available_assets() + assert available_assets >= 0 + assert available_assets <= total_assets + + +def test_share_price_calculation(sdk): + """Test share price calculation using convert functions""" + # Get 1 share worth of assets + one_share = Decimal('1.0') + assets_for_one_share = sdk.vault.convert_to_assets(one_share) + + # Get 1 asset worth of shares + one_asset = Decimal('1.0') + shares_for_one_asset = sdk.vault.convert_to_shares(one_asset) + + # These should be reciprocals of each other (allowing for rounding) + assert abs(assets_for_one_share * shares_for_one_asset - + Decimal('1.0')) < Decimal('0.01') + +# def test_max_deposit_and_mint(sdk, account): +# """Test max deposit and mint calculations""" +# # Test max deposit +# max_deposit = sdk.vault.max_deposit(account.address) +# assert max_deposit >= 0 + +# # Test max mint +# max_mint = sdk.vault.max_mint(account.address) +# assert max_mint >= 0 + +# # If we have a max deposit, we should be able to convert it to shares +# if max_deposit > 0: +# shares = sdk.vault.convert_to_shares(max_deposit) +# assert shares > 0 + + +def test_current_epoch_info(sdk): + """Test current epoch related functions""" + # Test current epoch + current_epoch = sdk.vault.get_current_epoch() + assert current_epoch >= 0 + + # Test current epoch start + epoch_start = sdk.vault.get_current_epoch_start() + assert epoch_start > 0 + + # Test withdraw epochs timelock + timelock = sdk.vault.get_withdraw_epochs_timelock() + assert timelock > 0 + + +def test_pnl_related_functions(sdk): + """Test PnL related functions""" + # Test accPnlPerToken + acc_pnl = sdk.vault.get_acc_pnl_per_token() + assert isinstance(acc_pnl, (int, Decimal)) + + # Test currentEpochPositiveOpenPnl + open_pnl = sdk.vault.get_current_epoch_positive_open_pnl() + assert open_pnl >= 0 + + +def test_balance_and_allowance(sdk, account): + """Test balance and allowance functions""" + # Test balance + balance = sdk.vault.get_balance(account.address) + assert balance >= 0 + + # Test allowance + allowance = sdk.vault.allowance(account.address, sdk.vault.vault_address) + assert allowance >= 0 + + +def test_withdraw_request_functions(sdk, account): + """Test withdraw request related functions""" + # Test total shares being withdrawn + shares_being_withdrawn = sdk.vault.total_shares_being_withdrawn( + account.address) + assert shares_being_withdrawn >= 0 + + # Test withdraw requests + current_epoch = sdk.vault.get_current_epoch() + withdraw_request = sdk.vault.withdraw_requests( + account.address, current_epoch) + assert withdraw_request >= 0 + + +def test_token_info(sdk): + """Test token information functions""" + # Test token name + name = sdk.vault.get_name() + assert name == "ostiumLP" + + # Test token symbol + symbol = sdk.vault.get_symbol() + assert symbol == "oLP" + + # Test token decimals + decimals = sdk.vault.get_decimals() + assert decimals == 6 + + +def test_zero_address_balance(sdk): + """Test balance for zero address""" + zero_address = "0x0000000000000000000000000000000000000000" + balance = sdk.vault.get_balance(zero_address) + assert balance == 0 + + +def test_max_redeem(sdk, account): + """Test max redeem function""" + max_redeem = sdk.vault.max_redeem(account.address) + assert max_redeem >= 0 + + +def test_withdrawal_timelock(sdk): + """Test withdrawal epochs timelock""" + timelock = sdk.vault.get_withdraw_epochs_timelock() + assert timelock > 0 + assert isinstance(timelock, int) + + +def test_vault_parameters(sdk): + """Test various vault parameters""" + # Test max supply + max_supply = sdk.vault.get_current_max_supply() + assert max_supply > 0 + + # Test max supply increase daily percentage + max_supply_increase = sdk.vault.get_max_supply_increase_daily_p() + assert max_supply_increase > 0 + assert max_supply_increase <= 1000 # Should be reasonable percentage + + # Test max discount percentage + max_discount = sdk.vault.get_max_discount_p() + assert max_discount > 0 + assert max_discount <= 100 # Should be reasonable percentage + + # Test max discount threshold percentage + max_discount_threshold = sdk.vault.get_max_discount_threshold_p() + assert max_discount_threshold > 0 + assert max_discount_threshold <= 1000 # Should be reasonable percentage + + +def test_discount_metrics(sdk): + """Test discount related metrics""" + # Test total discounts + total_discounts = sdk.vault.get_total_discounts() + assert total_discounts >= 0 + + # Test total locked discounts + total_locked_discounts = sdk.vault.get_total_locked_discounts() + assert total_locked_discounts >= 0 + assert total_locked_discounts <= total_discounts + + +def test_collateralization(sdk): + """Test collateralization metrics""" + # Test collateralization percentage + collat_p = sdk.vault.get_collateralization_p() + assert collat_p > 0 + assert collat_p <= 100 # Should be percentage + + # Test total deposited + total_deposited = sdk.vault.get_total_deposited() + assert total_deposited >= 0 + + # Test total liability + total_liability = sdk.vault.get_total_liability() + assert isinstance(total_liability, (int, Decimal)) + + +def test_rewards(sdk): + """Test rewards related functions""" + # Test total rewards + total_rewards = sdk.vault.get_total_rewards() + assert total_rewards >= 0 + + # Test acc rewards per token + acc_rewards = sdk.vault.get_acc_rewards_per_token() + assert acc_rewards >= 0 + + +def test_daily_metrics(sdk): + """Test daily metrics""" + # Test daily acc PnL delta per token + daily_pnl_delta = sdk.vault.get_daily_acc_pnl_delta_per_token() + assert isinstance(daily_pnl_delta, (int, Decimal)) + + # Test last daily acc PnL delta reset timestamp + last_reset = sdk.vault.get_last_daily_acc_pnl_delta_reset_ts() + assert last_reset > 0 + assert isinstance(last_reset, int) + + +def test_share_price(sdk): + """Test share price calculation""" + # Test share to assets price + share_price = sdk.vault.get_share_to_assets_price() + assert share_price > 0 + + # Verify price calculation + one_share = Decimal('1.0') + assets = sdk.vault.convert_to_assets(one_share) + + # Print values for debugging + print(f"\nShare price (18 decimals): {share_price}") + print(f"Assets (6 decimals): {assets}") + + # Convert share_price to 6 decimals for comparison + share_price_6_decimals = share_price * \ + Decimal('1000000000000') # Convert from 18 to 6 decimals + + # Calculate relative difference + relative_diff = abs(assets - share_price_6_decimals) / \ + share_price_6_decimals + # Allow for 0.1% difference due to rounding + assert relative_diff < Decimal( + '0.001'), f"Relative difference {relative_diff} exceeds tolerance" + + +def test_locked_deposits(sdk): + """Test locked deposits related functions""" + # Test locked deposits count + count = sdk.vault.get_locked_deposits_count() + assert count >= 0 + + # If there are locked deposits, test getting deposit info + if count > 0: + # Test getting first locked deposit + deposit = sdk.vault.get_locked_deposit(0) + assert deposit is not None + assert 'owner' in deposit + assert 'shares' in deposit + assert 'assetsDeposited' in deposit + assert 'lockDuration' in deposit + + # Test getting deposit through locked_deposits function + deposit_info = sdk.vault.locked_deposits(0) + assert deposit_info is not None + assert len(deposit_info) == 6 # Should return tuple of 6 values + + +def test_market_metrics(sdk): + """Test market cap and current balance""" + # Test market cap + market_cap = sdk.vault.get_market_cap() + assert market_cap >= 0 + + # Test current balance + current_balance = sdk.vault.get_current_balance() + assert current_balance >= 0 + + # Market cap should be related to total supply and share price + total_supply = sdk.vault.get_total_supply() + share_price = sdk.vault.get_share_to_assets_price() + expected_market_cap = total_supply * share_price + + # Allow for small rounding differences + relative_diff = abs(market_cap - expected_market_cap) / expected_market_cap + assert relative_diff < Decimal('0.001') + + +def test_preview_functions(sdk): + """Test preview functions for deposits and withdrawals""" + # Test preview deposit + assets = Decimal('1000.0') + preview_shares = sdk.vault.preview_deposit(assets) + assert preview_shares > 0 + + # Test preview mint + shares = Decimal('1000.0') + preview_assets = sdk.vault.preview_mint(shares) + assert preview_assets > 0 + + # Test preview redeem + preview_redeem_assets = sdk.vault.preview_redeem(shares) + assert preview_redeem_assets > 0 + + # Test preview withdraw + preview_withdraw_shares = sdk.vault.preview_withdraw(assets) + assert preview_withdraw_shares > 0 + + # Verify relationships between preview functions + # preview_deposit and preview_mint should be related + relative_diff = abs(preview_shares * preview_assets - + assets * shares) / (assets * shares) + assert relative_diff < Decimal('0.001') + + # preview_redeem and preview_withdraw should be related + relative_diff = abs(preview_redeem_assets * + preview_withdraw_shares - assets * shares) / (assets * shares) + assert relative_diff < Decimal('0.001') + + +def test_total_supply(sdk): + """Test total supply""" + # Get total supply + total_supply = sdk.vault.get_total_supply() + print(f"\nTotal supply: {total_supply}") + + # Check if it's 1 billion (10^9) + expected_supply = Decimal('1000000000') + assert total_supply == expected_supply, f"Total supply {total_supply} is not equal to expected {expected_supply}" + + +def test_locked_deposits(sdk): + """Test locked deposits related functions""" + # Test locked deposits count + count = sdk.vault.get_locked_deposits_count() + assert count >= 0 + + # If there are locked deposits, test getting deposit info + if count > 0: + # Test getting first locked deposit + deposit = sdk.vault.get_locked_deposit(0) + assert deposit is not None + assert 'owner' in deposit + assert 'shares' in deposit + assert 'assetsDeposited' in deposit + assert 'lockDuration' in deposit + + # Test getting deposit through locked_deposits function + deposit_info = sdk.vault.locked_deposits(0) + assert deposit_info is not None + assert len(deposit_info) == 6 # Should return tuple of 6 values + + +def test_market_metrics(sdk): + """Test market cap and current balance""" + # Test market cap + market_cap = sdk.vault.get_market_cap() + assert market_cap >= 0 + + # Test current balance + current_balance = sdk.vault.get_current_balance() + assert current_balance >= 0 + + # Market cap should be related to total supply and share price + total_supply = sdk.vault.get_total_supply() + share_price = sdk.vault.get_share_to_assets_price() + expected_market_cap = total_supply * share_price + + # Allow for small rounding differences + relative_diff = abs(market_cap - expected_market_cap) / expected_market_cap + assert relative_diff < Decimal('0.001') + + +def test_preview_functions(sdk): + """Test preview functions for deposits and withdrawals""" + # Test preview deposit + assets = Decimal('1000.0') + preview_shares = sdk.vault.preview_deposit(assets) + assert preview_shares > 0 + + # Test preview mint + shares = Decimal('1000.0') + preview_assets = sdk.vault.preview_mint(shares) + assert preview_assets > 0 + + # Test preview redeem + preview_redeem_assets = sdk.vault.preview_redeem(shares) + assert preview_redeem_assets > 0 + + # Test preview withdraw + preview_withdraw_shares = sdk.vault.preview_withdraw(assets) + assert preview_withdraw_shares > 0 + + # Verify relationships between preview functions + # preview_deposit and preview_mint should be related + relative_diff = abs(preview_shares * preview_assets - + assets * shares) / (assets * shares) + assert relative_diff < Decimal('0.001') + + # preview_redeem and preview_withdraw should be related + relative_diff = abs(preview_redeem_assets * + preview_withdraw_shares - assets * shares) / (assets * shares) + assert relative_diff < Decimal('0.001') + + +def test_total_supply(sdk): + """Test total supply and its relationships with other metrics""" + # Get total supply + total_supply = sdk.vault.get_total_supply() + assert total_supply >= 0 + + # Get total assets and TVL + total_assets = sdk.vault.get_total_assets() + tvl = sdk.vault.get_tvl() + + # Get share price + share_price = sdk.vault.get_share_to_assets_price() + + # Print values for debugging + print(f"\nTotal supply: {total_supply}") + print(f"Total assets: {total_assets}") + print(f"TVL: {tvl}") + print(f"Share price: {share_price}") + + # Total supply should be related to TVL and share price + # TVL = total_supply * share_price + expected_tvl = total_supply * share_price + + # Calculate relative difference + relative_diff = abs(tvl - expected_tvl) / expected_tvl if expected_tvl > 0 else 0 + # Allow for 0.1% difference due to rounding + assert relative_diff < Decimal('0.001'), f"Relative difference {relative_diff} exceeds tolerance" + + # Total assets should be less than or equal to TVL + assert total_assets <= tvl, "Total assets should not exceed TVL" + + # If there are assets, total supply should be positive + if total_assets > 0: + assert total_supply > 0, "Total supply should be positive when there are assets" + \ No newline at end of file