diff --git a/tinyman/liquid_staking/__init__.py b/tinyman/liquid_staking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tinyman/liquid_staking/base_client.py b/tinyman/liquid_staking/base_client.py new file mode 100644 index 0000000..8c048d1 --- /dev/null +++ b/tinyman/liquid_staking/base_client.py @@ -0,0 +1,122 @@ +import time +from base64 import b64decode, b64encode + +from algosdk import transaction +from algosdk.logic import get_application_address + +# TODO: move struct to parent. +from tinyman.liquid_staking.struct import get_struct, get_box_costs +from tinyman.utils import get_global_state, TransactionGroup + + +class BaseClient(): + def __init__(self, algod, app_id, user_address, user_sk) -> None: + self.algod = algod + self.app_id = app_id + self.application_address = get_application_address(self.app_id) + self.user_address = user_address + self.keys = {} + self.add_key(user_address, user_sk) + self.current_timestamp = None + self.simulate = False + + def get_suggested_params(self): + return self.algod.suggested_params() + + def get_current_timestamp(self): + return self.current_timestamp or time.time() + + def _submit(self, transactions, additional_fees=0): + transactions = self.flatten_transactions(transactions) + fee = transactions[0].fee + n = 0 + for txn in transactions: + if txn.fee == fee: + txn.fee = 0 + n += 1 + transactions[0].fee = (n + additional_fees) * fee + txn_group = TransactionGroup(transactions) + for address, key in self.keys.items(): + if isinstance(key, transaction.LogicSigAccount): + txn_group.sign_with_logicsig(key, address=address) + else: + txn_group.sign_with_private_key(address, key) + if self.simulate: + txn_info = self.algod.simulate_raw_transactions(txn_group.signed_transactions) + else: + txn_info = txn_group.submit(self.algod, wait=True) + return txn_info + + def flatten_transactions(self, txns): + result = [] + if isinstance(txns, transaction.Transaction): + result = [txns] + elif isinstance(txns, list): + for txn in txns: + result += self.flatten_transactions(txn) + return result + + def calculate_min_balance(self, accounts=0, assets=0, boxes=None): + cost = 0 + cost += accounts * 100_000 + cost += assets * 100_000 + cost += get_box_costs(boxes or {}) + return cost + + def add_key(self, address, key): + self.keys[address] = key + + def get_global(self, key, default=None, app_id=None): + app_id = app_id or self.app_id + global_state = {s["key"]: s["value"] for s in self.algod.application_info(app_id)["params"]["global-state"]} + key = b64encode(key).decode() + if key in global_state: + value = global_state[key] + if value["type"] == 2: + return value["uint"] + else: + return b64decode(value["bytes"]) + else: + return default + + def get_global_state(self, app_id=None): + app_id = app_id or self.app_id + + return get_global_state(self.algod, app_id) + + def get_box(self, box_name, struct_name, app_id=None): + app_id = app_id or self.app_id + box_value = b64decode(self.algod.application_box_by_name(app_id, box_name)["value"]) + struct_class = get_struct(struct_name) + struct = struct_class(box_value) + return struct + + def box_exists(self, box_name, app_id=None): + app_id = app_id or self.app_id + try: + self.algod.application_box_by_name(app_id, box_name) + return True + except Exception: + return False + + def get_reward_slot(self, staking_asset_id, reward_asset_id): + asset_box = self.get_asset_box(staking_asset_id) + for i in range(8): + if asset_box.reward_slots[i].asset_id == reward_asset_id: + return i + + def is_opted_in(self, address, asset_id): + try: + self.algod.account_asset_info(address, asset_id) + return True + except Exception: + return False + + def get_optin_if_needed_txn(self, sender, asset_id): + if not self.is_opted_in(sender, asset_id): + txn = transaction.AssetOptInTxn( + sender=sender, + sp=self.get_suggested_params(), + index=asset_id, + ) + return txn diff --git a/tinyman/liquid_staking/constants.py b/tinyman/liquid_staking/constants.py new file mode 100644 index 0000000..9af67ba --- /dev/null +++ b/tinyman/liquid_staking/constants.py @@ -0,0 +1,41 @@ +TESTNET_TALGO_APP_ID = 724519988 +MAINNET_TALGO_APP_ID = 2537013674 + +TESTNET_TALGO_STAKING_APP_ID = 724676904 +MAINNET_TALGO_STAKING_APP_ID = 2537022861 + +TESTNET_TALGO_ASSET_ID = 724519992 +MAINNET_TALGO_ASSET_ID = 2537013734 + +TESTNET_STALGO_ASSET_ID = 724676936 +MAINNET_STALGO_ASSET_ID = 2537023208 + +# App Constants + +APP_LOCAL_INTS = 3 +APP_LOCAL_BYTES = 1 +APP_GLOBAL_INTS = 16 +APP_GLOBAL_BYTES = 16 +EXTRA_PAGES = 3 + +VAULT_APP_ID_KEY = b"vault_app_id" +TINY_ASSET_ID_KEY = b"tiny_asset_id" +TALGO_ASSET_ID_KEY = b"talgo_asset_id" +STALGO_ASSET_ID_KEY = b"stalgo_asset_id" + +TOTAL_REWARD_AMOUNT_SUM_KEY = b"total_reward_amount_sum" +TOTAL_CLAIMED_REWARD_AMOUNT_KEY = b"total_claimed_reward_amount" +CURRENT_REWARD_RATE_PER_TIME_KEY = b"current_reward_rate_per_time" +CURRENT_REWARD_RATE_PER_TIME_END_TIMESTAMP_KEY = b"current_reward_rate_per_time_end_timestamp" + +TINY_POWER_THRESHOLD_KEY = b"tiny_power_threshold" +LAST_UPDATE_TIMESTAMP_KEY = b"last_update_timestamp" +ACCUMULATED_REWARDS_PER_UNIT = b"accumulated_rewards_per_unit" +TOTAL_STAKED_AMOUNT_KEY = b"total_staked_amount" +TOTAL_STAKER_COUNT_KEY = b"total_staker_count" + +PROPOSED_MANAGER_KEY = b"proposed_manager" +MANAGER_KEY = b"manager" + +MAX_UINT64 = 18446744073709551615 +RATE_SCALER = 1_000_000_000_000 diff --git a/tinyman/liquid_staking/events.py b/tinyman/liquid_staking/events.py new file mode 100644 index 0000000..48aedb6 --- /dev/null +++ b/tinyman/liquid_staking/events.py @@ -0,0 +1,249 @@ +from algosdk import abi + +from tinyman.event import Event + + +user_state_event = Event( + name="user_state", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="uint64", name="staked_amount"), + abi.Argument(arg_type="uint64", name="accumulated_rewards_per_unit_at_last_update"), + abi.Argument(arg_type="uint64", name="accumulated_rewards"), + abi.Argument(arg_type="uint64", name="timestamp"), + ] +) + + +create_application_event = Event( + name="create_application", + args=[ + abi.Argument(arg_type="uint64", name="talgo_asset_id"), + abi.Argument(arg_type="uint64", name="tiny_asset_id"), + abi.Argument(arg_type="uint64", name="vault_app_id"), + abi.Argument(arg_type="address", name="manager_address"), + ] +) + + +init_event = Event( + name="init", + args=[ + abi.Argument(arg_type="uint64", name="stalgo_asset_id") + ] +) + + +state_event = Event( + name="state", + args=[ + abi.Argument(arg_type="uint64", name="last_update_timestamp"), + abi.Argument(arg_type="uint64", name="current_reward_rate_per_time"), + abi.Argument(arg_type="uint64", name="accumulated_rewards_per_unit"), + abi.Argument(arg_type="uint64", name="total_staked_amount"), + ] +) + + +update_user_state_event = Event( + name="update_user_state", + args=[ + abi.Argument(arg_type="address", name="user_address"), + ] +) + + +set_reward_rate_event = Event( + name="set_reward_rate", + args=[ + abi.Argument(arg_type="uint64", name="total_reward_amount"), + abi.Argument(arg_type="uint64", name="start_timestamp"), + abi.Argument(arg_type="uint64", name="end_timestamp"), + abi.Argument(arg_type="uint64", name="current_reward_rate_per_time"), + ] +) + + +propose_manager_event = Event( + name="propose_manager", + args=[ + abi.Argument(arg_type="address", name="proposed_manager") + ] +) + + +accept_manager_event = Event( + name="accept_manager", + args=[ + abi.Argument(arg_type="address", name="new_manager") + ] +) + + +apply_rate_change_event = Event( + name="apply_rate_change", + args=[ + abi.Argument(arg_type="uint64", name="current_reward_rate_per_time"), + ] +) + + +increase_stake_event = Event( + name="increase_stake", + args=[ + abi.Argument(arg_type="uint64", name="amount"), + ] +) + + +decrease_stake_event = Event( + name="decrease_stake", + args=[ + abi.Argument(arg_type="uint64", name="amount"), + ] +) + + +claim_rewards_event = Event( + name="claim_rewards", + args=[ + abi.Argument(arg_type="uint64", name="amount"), + ] +) + + +set_tiny_power_threshold_event = Event( + name="set_tiny_power_threshold", + args=[ + abi.Argument(arg_type="uint64", name="threshold"), + ] +) + + +restaking_events = [ + create_application_event, + init_event, + state_event, + set_reward_rate_event, + propose_manager_event, + accept_manager_event, + set_tiny_power_threshold_event, + apply_rate_change_event, + user_state_event, + update_user_state_event, + increase_stake_event, + decrease_stake_event, + claim_rewards_event +] + + +rate_update_event = Event( + name="rate_update", + args=[ + abi.Argument(arg_type="uint64", name="rate"), + ] +) + + +mint_event = Event( + name="mint", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="uint64", name="algo_amount"), + abi.Argument(arg_type="uint64", name="talgo_amount"), + ] +) + + +burn_event = Event( + name="burn", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="uint64", name="talgo_amount"), + abi.Argument(arg_type="uint64", name="algo_amount"), + ] +) + + +claim_protocol_rewards_event = Event( + name="claim_protocol_rewards", + args=[ + abi.Argument(arg_type="uint64", name="amount") + ] +) + + +move_stake_event = Event( + name="move_stake", + args=[ + abi.Argument(arg_type="uint64", name="from_index"), + abi.Argument(arg_type="uint64", name="to_index"), + abi.Argument(arg_type="uint64", name="amount") + ] +) + + +set_node_manager_event = Event( + name="set_node_manager", + args=[ + abi.Argument(arg_type="uint64", name="node_index"), + abi.Argument(arg_type="address", name="new_node_manager") + ] +) + + +set_stake_manager_event = Event( + name="set_stake_manager", + args=[ + abi.Argument(arg_type="address", name="new_stake_manager") + ] +) + + +set_fee_collector_event = Event( + name="set_fee_collector", + args=[ + abi.Argument(arg_type="address", name="new_fee_collector") + ] +) + + +set_protocol_fee_event = Event( + name="set_protocol_fee", + args=[ + abi.Argument(arg_type="uint64", name="fee_rate") + ] +) + + +set_max_account_balance_event = Event( + name="set_max_account_balance", + args=[ + abi.Argument(arg_type="uint64", name="max_amount") + ] +) + + +change_online_status_event = Event( + name="change_online_status", + args=[ + abi.Argument(arg_type="uint64", name="node_index") + ] +) + + +talgo_events = [ + rate_update_event, + mint_event, + burn_event, + claim_protocol_rewards_event, + move_stake_event, + propose_manager_event, + accept_manager_event, + set_node_manager_event, + set_stake_manager_event, + set_fee_collector_event, + set_protocol_fee_event, + set_max_account_balance_event, + change_online_status_event, +] diff --git a/tinyman/liquid_staking/struct.py b/tinyman/liquid_staking/struct.py new file mode 100644 index 0000000..5685833 --- /dev/null +++ b/tinyman/liquid_staking/struct.py @@ -0,0 +1,133 @@ +# TODO: move struct to parent. +import json +import re +from typing import Any + + +MINIMUM_BALANCE_REQUIREMENT_PER_BOX = 2_500 +MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE = 400 + + +structs = json.load(open("structs.json"))["structs"] + + +class Struct(): + def __init__(self, name, size, fields): + self._name = name + self._size = size + self._fields = fields + + def __call__(self, data=None) -> Any: + if data is None: + data = bytearray(self._size) + self._data = memoryview(data) + return self + + def __getattribute__(self, name: str) -> Any: + if name.startswith("_"): + return super().__getattribute__(name) + field = self._fields[name] + start = field["offset"] + end = field["offset"] + field["size"] + value = self._data[start:end] + type = get_type(field["type"]) + return type(value) + + def __setattr__(self, name: str, value: Any) -> None: + if name.startswith("_"): + return super().__setattr__(name, value) + field = self._fields[name] + start = field["offset"] + end = field["offset"] + field["size"] + if field["type"] in ("int",): + value = value.to_bytes(field["size"], "big") + if isinstance(value, (Struct, ArrayData)): + value = value._data + self._data[start:end] = value + + def __setitem__(self, index, value): + if isinstance(value, (Struct, ArrayData)): + value = value._data + self._data[:] = value + + def __str__(self) -> str: + return repr(bytes(self._data)) + + def __repr__(self) -> str: + fields = {f: getattr(self, f) for f in self._fields} + return f"{self._name}({fields})" + + def __len__(self): + return len(self._data) + + def __conform__(self, protocol): + return bytes(self._data) + + def __bytes__(self): + return bytes(self._data.tobytes()) + + +class ArrayData(): + def __init__(self, struct, length): + self._struct = struct + self._length = length + + def __call__(self, data=None) -> Any: + if data is None: + data = bytearray(self._struct._size * self.length) + self._data = memoryview(data) + return self + + def __getitem__(self, index): + offset = self._struct._size * index + end = offset + self._struct._size + value = self._data[offset:end] + return self._struct(value) + + def __setitem__(self, index, value): + offset = self._struct._size * index + end = offset + self._struct._size + if isinstance(value, Struct): + value = value._data + self._data[offset:end] = value + + def __repr__(self) -> str: + return ", ".join(repr(self[i]) for i in range(self._length)) + + +class TealishInt(): + def __call__(self, value) -> Any: + return int.from_bytes(value, "big") + + +class TealishBytes(): + def __call__(self, value) -> Any: + return value + + +def get_struct(name): + return Struct(name=name, **structs[name]) + + +def get_type(name): + if name == "int": + return TealishInt() + elif name.startswith("uint"): + return TealishInt() + elif name.startswith("bytes"): + return TealishBytes() + elif name in structs: + return Struct(**structs[name]) + elif "[" in name: + name, length = re.match(r"([A-Za-z_0-9]+)\[(\d+)\]", name).groups() + return ArrayData(Struct(**structs[name]), int(length)) + else: + raise KeyError(name) + + +def get_box_costs(boxes): + cost = MINIMUM_BALANCE_REQUIREMENT_PER_BOX + for name, struct in boxes.items(): + cost += len(name) * MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE + cost += struct._size * MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE + return cost diff --git a/tinyman/liquid_staking/structs.json b/tinyman/liquid_staking/structs.json new file mode 100644 index 0000000..89d28ec --- /dev/null +++ b/tinyman/liquid_staking/structs.json @@ -0,0 +1,29 @@ +{ + "structs": { + "UserState": { + "size": 32, + "fields": { + "staked_amount": { + "type": "int", + "size": 8, + "offset": 0 + }, + "accumulated_rewards_per_unit_at_last_update": { + "type": "int", + "size": 8, + "offset": 8 + }, + "accumulated_rewards": { + "type": "int", + "size": 8, + "offset": 16 + }, + "timestamp": { + "type": "int", + "size": 8, + "offset": 24 + } + } + } + } +} diff --git a/tinyman/liquid_staking/talgo_client.py b/tinyman/liquid_staking/talgo_client.py new file mode 100644 index 0000000..16757c9 --- /dev/null +++ b/tinyman/liquid_staking/talgo_client.py @@ -0,0 +1,223 @@ +from algosdk import transaction +from algosdk.encoding import decode_address, encode_address +from .base_client import BaseClient + + +class TAlgoClient(BaseClient): + + keyreg_lsig = transaction.LogicSigAccount(b"\n\x81\x01C") + + def init(self): + sp = self.get_suggested_params() + transactions = [ + transaction.PaymentTxn( + sender=self.user_address, + receiver=self.application_address, + sp=sp, + amt=2_000_000, + ), + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["init"], + accounts=[], + foreign_assets=[] + ), + ] + return self._submit(transactions, additional_fees=13) + + def sync(self): + sp = self.get_suggested_params() + transactions = [ + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["sync"], + accounts=[ + encode_address(self.get_global(b"account_1")), + encode_address(self.get_global(b"account_2")), + encode_address(self.get_global(b"account_3")), + encode_address(self.get_global(b"account_4")), + ], + foreign_assets=[] + ), + ] + return self._submit(transactions, additional_fees=0) + + def mint(self, amount): + sp = self.get_suggested_params() + transactions = [ + self.get_optin_if_needed_txn(self.user_address, self.get_global(b"talgo_asset_id")), + transaction.PaymentTxn( + sender=self.user_address, + receiver=self.application_address, + sp=sp, + amt=amount, + ), + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["mint", amount], + accounts=[ + encode_address(self.get_global(b"account_1")), + encode_address(self.get_global(b"account_2")), + encode_address(self.get_global(b"account_3")), + encode_address(self.get_global(b"account_4")), + ], + foreign_assets=[ + self.get_global(b"talgo_asset_id"), + ] + ), + ] + return self._submit(transactions, additional_fees=4) + + def burn(self, amount): + sp = self.get_suggested_params() + transactions = [ + transaction.AssetTransferTxn( + sender=self.user_address, + receiver=self.application_address, + sp=sp, + amt=amount, + index=self.get_global(b"talgo_asset_id") + ), + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["burn", amount], + accounts=[ + encode_address(self.get_global(b"account_1")), + encode_address(self.get_global(b"account_2")), + encode_address(self.get_global(b"account_3")), + encode_address(self.get_global(b"account_4")), + ], + foreign_assets=[ + ] + ), + ] + return self._submit(transactions, additional_fees=1) + + def go_online(self, node_index, vote_pk, selection_pk, state_proof_pk, vote_first, vote_last, vote_key_dilution, fee): + account_address = encode_address(self.get_global(b"account_%i" % node_index)) + self.add_key(account_address, self.keyreg_lsig) + sp = self.get_suggested_params() + transactions = [ + transaction.PaymentTxn( + sender=self.user_address, + receiver=account_address, + sp=sp, + amt=fee + ) if fee else None, + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["change_online_status", node_index], + accounts=[ + ], + foreign_assets=[ + ] + ), + transaction.KeyregOnlineTxn( + sender=account_address, + sp=sp, + rekey_to=self.application_address, + votekey=vote_pk, + selkey=selection_pk, + votefst=vote_first, + votelst=vote_last, + votekd=vote_key_dilution, + sprfkey=state_proof_pk, + ) + ] + if fee: + transactions[2].fee = fee + return self._submit(transactions, additional_fees=1) + + def go_offline(self, node_index): + account_address = encode_address(self.get_global(b"account_%i" % node_index)) + self.add_key(account_address, self.keyreg_lsig) + sp = self.get_suggested_params() + transactions = [ + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["change_online_status", node_index], + accounts=[ + ], + foreign_assets=[ + ] + ), + transaction.KeyregOfflineTxn( + sender=account_address, + sp=sp, + rekey_to=self.application_address, + ) + ] + return self._submit(transactions, additional_fees=1) + + def set_node_manager(self, node_index, node_manager_address): + sp = self.get_suggested_params() + transactions = [ + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["set_node_manager", node_index, decode_address(node_manager_address)], + accounts=[ + ], + foreign_assets=[] + ), + ] + return self._submit(transactions, additional_fees=0) + + def move_stake(self, from_node_index, to_node_index, amount): + sp = self.get_suggested_params() + transactions = [ + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["move_stake", from_node_index, to_node_index, amount], + accounts=[ + encode_address(self.get_global(b"account_1")), + encode_address(self.get_global(b"account_2")), + encode_address(self.get_global(b"account_3")), + encode_address(self.get_global(b"account_4")), + ], + foreign_assets=[] + ), + ] + return self._submit(transactions, additional_fees=1) + + def claim_protocol_rewards(self): + sp = self.get_suggested_params() + transactions = [ + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["claim_protocol_rewards"], + accounts=[ + encode_address(self.get_global(b"fee_collector")), + ], + foreign_assets=[ + self.get_global(b"talgo_asset_id"), + ] + ), + ] + return self._submit(transactions, additional_fees=1) diff --git a/tinyman/liquid_staking/talgo_staking_client.py b/tinyman/liquid_staking/talgo_staking_client.py new file mode 100644 index 0000000..b436ff6 --- /dev/null +++ b/tinyman/liquid_staking/talgo_staking_client.py @@ -0,0 +1,175 @@ +from datetime import datetime, timezone + +from algosdk import transaction +from algosdk.encoding import decode_address +from algosdk.logic import get_application_address + +from tinyman.liquid_staking.base_client import BaseClient +from tinyman.liquid_staking.constants import * +from tinyman.liquid_staking.struct import get_struct + + +UserState = get_struct("UserState") + + +class TAlgoStakingClient(BaseClient): + def __init__(self, algod, staking_app_id, vault_app_id, tiny_asset_id, talgo_asset_id, stalgo_asset_id, user_address, user_sk) -> None: + self.algod = algod + self.app_id = staking_app_id + self.application_address = get_application_address(self.app_id) + self.vault_app_id = vault_app_id + self.tiny_asset_id = tiny_asset_id + self.talgo_asset_id = talgo_asset_id + self.stalgo_asset_id = stalgo_asset_id + self.user_address = user_address + self.keys = {} + self.add_key(user_address, user_sk) + self.current_timestamp = None + self.simulate = False + + def set_reward_rate(self, total_reward_amount: int, end_timestamp: int): + sp = self.get_suggested_params() + + transactions = [ + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["set_reward_rate", total_reward_amount, end_timestamp], + ) + ] + + return self._submit(transactions) + + def get_apply_rate_change_txn(self): + sp = self.get_suggested_params() + + txn = [ + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["apply_rate_change"], + ) + ] + + return txn + + def get_apply_rate_change_txn_if_needed(self): + now = datetime.now(tz=timezone.utc).timestamp() + current_rate_end_timestamp = self.get_global(CURRENT_REWARD_RATE_PER_TIME_END_TIMESTAMP_KEY) + + if current_rate_end_timestamp <= now: + return self.get_apply_rate_change_txn() + + def apply_rate_change(self): + transactions = [self.get_apply_rate_change_txn()] + + return self._submit(transactions) + + def update_state(self): + sp = self.get_suggested_params() + + transactions = [ + self.get_apply_rate_change_txn_if_needed(), + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["update_state"], + ) + ] + + return self._submit(transactions) + + def get_user_state_box_name(self, account_address: str): + return decode_address(account_address) + + def increase_stake(self, amount: int): + sp = self.get_suggested_params() + + user_state_box_name = self.get_user_state_box_name(self.user_address) + new_boxes = {} + if not self.box_exists(user_state_box_name): + new_boxes[user_state_box_name] = UserState + + transactions = [ + self.get_apply_rate_change_txn_if_needed(), + transaction.PaymentTxn( + sender=self.user_address, + sp=sp, + receiver=self.application_address, + amt=self.calculate_min_balance(boxes=new_boxes) + ) if new_boxes else None, + self.get_optin_if_needed_txn(self.user_address, self.stalgo_asset_id), + transaction.AssetTransferTxn( + index=self.talgo_asset_id, + sender=self.user_address, + receiver=self.application_address, + sp=sp, + amt=amount + ), + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["increase_stake", amount], + foreign_apps=[self.vault_app_id], + foreign_assets=[self.stalgo_asset_id], + boxes=[ + (0, user_state_box_name), + (self.vault_app_id, user_state_box_name), + ], + ) + ] + + return self._submit(transactions, additional_fees=2) + + def decrease_stake(self, amount: int): + sp = self.get_suggested_params() + user_state_box_name = self.get_user_state_box_name(self.user_address) + + transactions = [ + self.get_apply_rate_change_txn_if_needed(), + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["decrease_stake", amount], + boxes=[ + (0, user_state_box_name), + ], + foreign_assets=[self.talgo_asset_id, self.stalgo_asset_id], + ) + ] + + return self._submit(transactions, additional_fees=2) + + def claim_rewards(self): + sp = self.get_suggested_params() + user_state_box_name = self.get_user_state_box_name(self.user_address) + + transactions = [ + self.get_apply_rate_change_txn_if_needed(), + self.get_optin_if_needed_txn(self.user_address, self.tiny_asset_id), + transaction.ApplicationCallTxn( + sender=self.user_address, + on_complete=transaction.OnComplete.NoOpOC, + sp=sp, + index=self.app_id, + app_args=["claim_rewards"], + foreign_apps=[self.vault_app_id], + foreign_assets=[self.tiny_asset_id], + boxes=[ + (0, user_state_box_name), + (self.vault_app_id, user_state_box_name), + ], + ) + ] + + return self._submit(transactions, additional_fees=3) diff --git a/tinyman/liquid_staking/utils.py b/tinyman/liquid_staking/utils.py new file mode 100644 index 0000000..f43b059 --- /dev/null +++ b/tinyman/liquid_staking/utils.py @@ -0,0 +1,33 @@ +from decimal import Decimal + +from tinyman.liquid_staking.constants import * +from tinyman.utils import get_global_state + + +def calculate_talgo_to_algo_ratio(algod): + global_state = get_global_state(algod, MAINNET_TALGO_APP_ID) + + LIQUID_STAKING_NODE_ADDRESSES = [ + "EP2YRTCL3SAA7HYG7KKWUC6ZH36SLYIKOX4FORKXZLUUQASP5JDJP4UU5A", + "D6CCE7DL3GSVOCQDPWMNR5V7JEKGXOJACCU4A4K76DLJHZ4H47WRVBPUNY", + "UTTJ2JOAXXAZEMKFSRNKFW4OIPMETRORCHNCDEDAHBJ5THNZTLWS6ZLUYU", + "3X3CIVGQGHVVGMJ627NQUXPN3EVLOR6ZPDXJ4XZGFW5DQVXFBGUKEKOEEI", + "F66MBWKUEG5GXZB4HFIZJRSMNYOATH2URKQBTBKKI7ZJAA2IFUFKXLHTOA", + ] + app_account = LIQUID_STAKING_NODE_ADDRESSES[0] + account_info = algod.account_info(app_account) + + algo_balance = account_info["amount"] - account_info["min-balance"] + talgo_balance = account_info["assets"][0]["amount"] - global_state["protocol_talgo"] + + for address in LIQUID_STAKING_NODE_ADDRESSES[1:]: + account_info = algod.account_info(address) + algo_balance += (account_info["amount"] - account_info["min-balance"]) + + TALGO_TOTAL_SUPPLY = 10_000_000_000_000_000 + minted_talgo = TALGO_TOTAL_SUPPLY - talgo_balance + new_rewards = algo_balance - global_state["algo_balance"] + protocol_rewards = (new_rewards * global_state["protocol_fee"]) / 100 + rate = Decimal(algo_balance - protocol_rewards) / Decimal(minted_talgo) + + return rate