From b849bc098f9f93c7dc88e816ab64072969cf837d Mon Sep 17 00:00:00 2001 From: etzellux Date: Wed, 31 Jul 2024 12:54:42 +0300 Subject: [PATCH 1/2] add governance and lending pools features --- examples/folks_lending/add_liquidity.py | 53 ++ examples/folks_lending/create_new_pool.py | 97 +++ examples/folks_lending/remove_liquidity.py | 52 ++ examples/governance/00_opt_in_to_tiny.py | 27 + examples/governance/01_create_lock.py | 46 ++ .../governance/02_extend_lock_end_time.py | 39 + .../governance/03_increase_lock_amount.py | 36 + ...se_lock_amount_and_extend_lock_end_time.py | 39 + examples/governance/05_withdraw.py | 34 + examples/governance/10_claim_reward.py | 45 + .../20_staking_proposal_cast_vote.py | 32 + examples/governance/30_create_proposal.py | 47 ++ examples/governance/31_cast_vote.py | 27 + examples/governance/99_create_checkpoints.py | 33 + examples/governance/__init__.py | 0 tinyman/constants.py | 17 + tinyman/folks_lending/__init__.py | 0 tinyman/folks_lending/constants.py | 5 + tinyman/folks_lending/transactions.py | 167 ++++ tinyman/folks_lending/utils.py | 98 +++ tinyman/governance/__init__.py | 0 tinyman/governance/client.py | 766 +++++++++++++++++ tinyman/governance/constants.py | 40 + tinyman/governance/event.py | 82 ++ .../governance/proposal_voting/__init__.py | 0 .../governance/proposal_voting/constants.py | 67 ++ tinyman/governance/proposal_voting/events.py | 147 ++++ .../governance/proposal_voting/exceptions.py | 2 + .../proposal_voting/executor_transactions.py | 292 +++++++ tinyman/governance/proposal_voting/storage.py | 109 +++ .../proposal_voting/transactions.py | 403 +++++++++ tinyman/governance/rewards/__init__.py | 0 tinyman/governance/rewards/constants.py | 38 + tinyman/governance/rewards/events.py | 86 ++ tinyman/governance/rewards/storage.py | 142 ++++ tinyman/governance/rewards/transactions.py | 288 +++++++ tinyman/governance/rewards/utils.py | 26 + tinyman/governance/staking_voting/__init__.py | 0 .../governance/staking_voting/constants.py | 31 + tinyman/governance/staking_voting/events.py | 94 +++ tinyman/governance/staking_voting/storage.py | 79 ++ .../governance/staking_voting/transactions.py | 222 +++++ tinyman/governance/transactions.py | 154 ++++ tinyman/governance/utils.py | 118 +++ tinyman/governance/vault/__init__.py | 0 tinyman/governance/vault/constants.py | 61 ++ tinyman/governance/vault/events.py | 123 +++ tinyman/governance/vault/exceptions.py | 14 + tinyman/governance/vault/storage.py | 236 ++++++ tinyman/governance/vault/transactions.py | 770 ++++++++++++++++++ tinyman/governance/vault/utils.py | 56 ++ tinyman/utils.py | 41 +- 52 files changed, 5375 insertions(+), 6 deletions(-) create mode 100644 examples/folks_lending/add_liquidity.py create mode 100644 examples/folks_lending/create_new_pool.py create mode 100644 examples/folks_lending/remove_liquidity.py create mode 100644 examples/governance/00_opt_in_to_tiny.py create mode 100644 examples/governance/01_create_lock.py create mode 100644 examples/governance/02_extend_lock_end_time.py create mode 100644 examples/governance/03_increase_lock_amount.py create mode 100644 examples/governance/04_increase_lock_amount_and_extend_lock_end_time.py create mode 100644 examples/governance/05_withdraw.py create mode 100644 examples/governance/10_claim_reward.py create mode 100644 examples/governance/20_staking_proposal_cast_vote.py create mode 100644 examples/governance/30_create_proposal.py create mode 100644 examples/governance/31_cast_vote.py create mode 100644 examples/governance/99_create_checkpoints.py create mode 100644 examples/governance/__init__.py create mode 100644 tinyman/constants.py create mode 100644 tinyman/folks_lending/__init__.py create mode 100644 tinyman/folks_lending/constants.py create mode 100644 tinyman/folks_lending/transactions.py create mode 100644 tinyman/folks_lending/utils.py create mode 100644 tinyman/governance/__init__.py create mode 100644 tinyman/governance/client.py create mode 100644 tinyman/governance/constants.py create mode 100644 tinyman/governance/event.py create mode 100644 tinyman/governance/proposal_voting/__init__.py create mode 100644 tinyman/governance/proposal_voting/constants.py create mode 100644 tinyman/governance/proposal_voting/events.py create mode 100644 tinyman/governance/proposal_voting/exceptions.py create mode 100644 tinyman/governance/proposal_voting/executor_transactions.py create mode 100644 tinyman/governance/proposal_voting/storage.py create mode 100644 tinyman/governance/proposal_voting/transactions.py create mode 100644 tinyman/governance/rewards/__init__.py create mode 100644 tinyman/governance/rewards/constants.py create mode 100644 tinyman/governance/rewards/events.py create mode 100644 tinyman/governance/rewards/storage.py create mode 100644 tinyman/governance/rewards/transactions.py create mode 100644 tinyman/governance/rewards/utils.py create mode 100644 tinyman/governance/staking_voting/__init__.py create mode 100644 tinyman/governance/staking_voting/constants.py create mode 100644 tinyman/governance/staking_voting/events.py create mode 100644 tinyman/governance/staking_voting/storage.py create mode 100644 tinyman/governance/staking_voting/transactions.py create mode 100644 tinyman/governance/transactions.py create mode 100644 tinyman/governance/utils.py create mode 100644 tinyman/governance/vault/__init__.py create mode 100644 tinyman/governance/vault/constants.py create mode 100644 tinyman/governance/vault/events.py create mode 100644 tinyman/governance/vault/exceptions.py create mode 100644 tinyman/governance/vault/storage.py create mode 100644 tinyman/governance/vault/transactions.py create mode 100644 tinyman/governance/vault/utils.py diff --git a/examples/folks_lending/add_liquidity.py b/examples/folks_lending/add_liquidity.py new file mode 100644 index 0000000..8b3213c --- /dev/null +++ b/examples/folks_lending/add_liquidity.py @@ -0,0 +1,53 @@ +from algosdk.v2client.algod import AlgodClient + +from tinyman.folks_lending.constants import ( + TESTNET_FOLKS_POOL_MANAGER_APP_ID, + TESTNET_FOLKS_WRAPPER_LENDING_POOL_APP_ID) +from tinyman.folks_lending.transactions import \ + prepare_add_liquidity_transaction_group +from tinyman.folks_lending.utils import get_lending_pools +from tinyman.v2.client import TinymanV2TestnetClient +from tinyman.v2.constants import TESTNET_VALIDATOR_APP_ID_V2 + +algod = AlgodClient("", "https://testnet-api.algonode.network") +account_sk, account_address = ('YOUR PRIVATE KEY HERE', 'YOUR ADDRESS HERE') +client = TinymanV2TestnetClient(algod_client=algod, user_address=account_address) + +asset_1_id = 67395862 # USDC +asset_2_id = 0 # Algo + +# Get f_asset ids + +folks_pools = get_lending_pools(algod, TESTNET_FOLKS_POOL_MANAGER_APP_ID) +temp = dict() +for folks_pool in folks_pools: + temp[folks_pool['asset_id']] = folks_pool +folks_pools = temp + +f_asset_1_id = folks_pools[asset_1_id]['f_asset_id'] +f_asset_2_id = folks_pools[asset_2_id]['f_asset_id'] + +pool = client.fetch_pool(f_asset_1_id, f_asset_2_id, fetch=True) + +# Add liquidity + +txn_group = prepare_add_liquidity_transaction_group( + sender=account_address, + suggested_params=algod.suggested_params(), + wrapper_app_id=TESTNET_FOLKS_WRAPPER_LENDING_POOL_APP_ID, + tinyman_amm_app_id=TESTNET_VALIDATOR_APP_ID_V2, + lending_app_1_id=folks_pools[asset_1_id]['pool_app_id'], + lending_app_2_id=folks_pools[asset_2_id]['pool_app_id'], + lending_manager_app_id=TESTNET_FOLKS_POOL_MANAGER_APP_ID, + tinyman_pool_address=pool.address, + asset_1_id=asset_1_id, + asset_2_id=asset_2_id, + f_asset_1_id=f_asset_1_id, + f_asset_2_id=f_asset_2_id, + liquidity_token_id=pool.pool_token_asset.id, + asset_1_amount=10000, + asset_2_amount=10000 +) + +txn_group.sign_with_private_key(account_address, account_sk) +txn_group.submit(algod, True) diff --git a/examples/folks_lending/create_new_pool.py b/examples/folks_lending/create_new_pool.py new file mode 100644 index 0000000..061d3d0 --- /dev/null +++ b/examples/folks_lending/create_new_pool.py @@ -0,0 +1,97 @@ +from algosdk import transaction +from algosdk.v2client.algod import AlgodClient + +from tinyman.folks_lending.constants import ( + TESTNET_FOLKS_POOL_MANAGER_APP_ID, + TESTNET_FOLKS_WRAPPER_LENDING_POOL_APP_ID) +from tinyman.folks_lending.transactions import \ + prepare_asset_optin_transaction_group +from tinyman.folks_lending.utils import get_lending_pools +from tinyman.utils import TransactionGroup +from tinyman.v2.client import TinymanV2TestnetClient + +algod = AlgodClient("", "https://testnet-api.algonode.network") +account_sk, account_address = ('YOUR PRIVATE KEY HERE', 'YOUR ADDRESS HERE') +client = TinymanV2TestnetClient(algod_client=algod, user_address=account_address) + +asset_1_id = 67396528 # goBTC +asset_2_id = 0 # Algo + +# Get f_asset ids + +folks_pools = get_lending_pools(algod, TESTNET_FOLKS_POOL_MANAGER_APP_ID) +temp = dict() +for folks_pool in folks_pools: + temp[folks_pool['asset_id']] = folks_pool +folks_pools = temp + +f_asset_1_id = folks_pools[asset_1_id]['f_asset_id'] +f_asset_2_id = folks_pools[asset_2_id]['f_asset_id'] + +pool = client.fetch_pool(f_asset_1_id, f_asset_2_id) + +# Opt-in to assets + +txns = [ + transaction.AssetOptInTxn( + sender=account_address, + sp=algod.suggested_params(), + index=asset_1_id + ), + transaction.AssetOptInTxn( + sender=account_address, + sp=algod.suggested_params(), + index=f_asset_1_id + ), + transaction.AssetOptInTxn( + sender=account_address, + sp=algod.suggested_params(), + index=f_asset_2_id + ) +] + +if asset_2_id != 0: + txns.append( + transaction.AssetOptInTxn( + sender=account_address, + sp=algod.suggested_params(), + index=asset_2_id + ) + ) +txn_group = TransactionGroup(txns) +txn_group.sign_with_private_key(account_address, account_sk) +txn_group.submit(algod, True) + +# Bootstrap pool. + +txn_group = pool.prepare_bootstrap_transactions( + user_address=account_address, + suggested_params=algod.suggested_params(), +) +txn_group.sign_with_private_key(account_address, account_sk) +txn_group.submit(algod, True) + +# Opt-in to pool token. + +pool = client.fetch_pool(f_asset_1_id, f_asset_2_id, fetch=True) + +txn_group = TransactionGroup([ + transaction.AssetOptInTxn( + sender=account_address, + sp=algod.suggested_params(), + index=pool.pool_token_asset.id + ) +]) +txn_group.sign_with_private_key(account_address, account_sk) +txn_group.submit(algod, True) + +# Send an asset_optin appcall. + +txn_group = prepare_asset_optin_transaction_group( + sender=account_address, + suggested_params=algod.suggested_params(), + wrapper_app_id=TESTNET_FOLKS_WRAPPER_LENDING_POOL_APP_ID, + assets_to_optin=[asset_1_id, asset_2_id, f_asset_1_id, f_asset_2_id, pool.pool_token_asset.id] +) +txn_group.sign_with_private_key(account_address, account_sk) +txn_group.submit(algod, True) diff --git a/examples/folks_lending/remove_liquidity.py b/examples/folks_lending/remove_liquidity.py new file mode 100644 index 0000000..d70e8cb --- /dev/null +++ b/examples/folks_lending/remove_liquidity.py @@ -0,0 +1,52 @@ +from algosdk.v2client.algod import AlgodClient + +from tinyman.folks_lending.constants import ( + TESTNET_FOLKS_POOL_MANAGER_APP_ID, + TESTNET_FOLKS_WRAPPER_LENDING_POOL_APP_ID) +from tinyman.folks_lending.transactions import \ + prepare_remove_liquidity_transaction_group +from tinyman.folks_lending.utils import get_lending_pools +from tinyman.v2.client import TinymanV2TestnetClient +from tinyman.v2.constants import TESTNET_VALIDATOR_APP_ID_V2 + +algod = AlgodClient("", "https://testnet-api.algonode.network") +account_sk, account_address = ('YOUR PRIVATE KEY HERE', 'YOUR ADDRESS HERE') +client = TinymanV2TestnetClient(algod_client=algod, user_address=account_address) + +asset_1_id = 67395862 # USDC +asset_2_id = 0 # Algo + +# Get f_asset ids + +folks_pools = get_lending_pools(algod, TESTNET_FOLKS_POOL_MANAGER_APP_ID) +temp = dict() +for folks_pool in folks_pools: + temp[folks_pool['asset_id']] = folks_pool +folks_pools = temp + +f_asset_1_id = folks_pools[asset_1_id]['f_asset_id'] +f_asset_2_id = folks_pools[asset_2_id]['f_asset_id'] + +pool = client.fetch_pool(f_asset_1_id, f_asset_2_id, fetch=True) + +# Remove liquidity + +txn_group = prepare_remove_liquidity_transaction_group( + sender=account_address, + suggested_params=algod.suggested_params(), + wrapper_app_id=TESTNET_FOLKS_WRAPPER_LENDING_POOL_APP_ID, + tinyman_amm_app_id=TESTNET_VALIDATOR_APP_ID_V2, + lending_app_1_id=folks_pools[asset_1_id]['pool_app_id'], + lending_app_2_id=folks_pools[asset_2_id]['pool_app_id'], + lending_manager_app_id=TESTNET_FOLKS_POOL_MANAGER_APP_ID, + tinyman_pool_address=pool.address, + asset_1_id=asset_1_id, + asset_2_id=asset_2_id, + f_asset_1_id=f_asset_1_id, + f_asset_2_id=f_asset_2_id, + liquidity_token_id=pool.pool_token_asset.id, + liquidity_token_amount=1_000_000 +) + +txn_group.sign_with_private_key(account_address, account_sk) +txn_group.submit(algod, True) diff --git a/examples/governance/00_opt_in_to_tiny.py b/examples/governance/00_opt_in_to_tiny.py new file mode 100644 index 0000000..460f62d --- /dev/null +++ b/examples/governance/00_opt_in_to_tiny.py @@ -0,0 +1,27 @@ +from examples.v2.utils import get_algod +from tinyman.governance.client import TinymanGovernanceTestnetClient + +from tinyman.governance.constants import TESTNET_TINY_ASSET_ID + +# Hardcoding account keys is not a great practice. This is for demonstration purposes only. +# See the README & Docs for alternative signing methods. +account = { + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", +} + +algod = get_algod() + +# Client +governance_client = TinymanGovernanceTestnetClient( + algod_client=algod, + user_address=account["address"] +) + +if not governance_client.asset_is_opted_in(TESTNET_TINY_ASSET_ID): + txn_group = governance_client.prepare_asset_optin_transactions(TESTNET_TINY_ASSET_ID) + txn_group.sign_with_private_key(address=account["address"], private_key=account["private_key"]) + result = txn_group.submit(algod, wait=True) + print("TXN:", result) + +print("Get some TINY token.") diff --git a/examples/governance/01_create_lock.py b/examples/governance/01_create_lock.py new file mode 100644 index 0000000..27a246f --- /dev/null +++ b/examples/governance/01_create_lock.py @@ -0,0 +1,46 @@ +import time + +from examples.v2.utils import get_algod +from tinyman.governance.client import TinymanGovernanceTestnetClient +from tinyman.governance.constants import WEEK +from tinyman.governance.vault.constants import MIN_LOCK_TIME + +# Hardcoding account keys is not a great practice. This is for demonstration purposes only. +# See the README & Docs for alternative signing methods. +account = { + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", +} + +algod = get_algod() + +# Client +governance_client = TinymanGovernanceTestnetClient( + algod_client=algod, + user_address=account["address"] +) + +account_state = governance_client.fetch_account_state() +print("Account State before TXN:", account_state) + +end_timestamp_of_current_week = (int(time.time()) // WEEK + 1) * WEEK +lock_end_timestamp = end_timestamp_of_current_week + MIN_LOCK_TIME + +# lock_end_timestamp = int(time.time()) + 100 + +txn_group = governance_client.prepare_create_lock_transactions( + locked_amount=10_000_000, + lock_end_time=lock_end_timestamp, +) +txn_group.sign_with_private_key(account["address"], account["private_key"]) +txn_group.submit(algod, wait=True) + +account_state = governance_client.fetch_account_state() +print("Account State after TXN:", account_state) + +tiny_power = governance_client.get_tiny_power() +print("TINY POWER:", tiny_power) + +total_tiny_power = governance_client.get_total_tiny_power() +print("Total TINY POWER:", total_tiny_power) +print(f"User TINY Power %{(tiny_power / total_tiny_power) * 100}") diff --git a/examples/governance/02_extend_lock_end_time.py b/examples/governance/02_extend_lock_end_time.py new file mode 100644 index 0000000..8f62085 --- /dev/null +++ b/examples/governance/02_extend_lock_end_time.py @@ -0,0 +1,39 @@ +from examples.v2.utils import get_algod +from tinyman.governance.client import TinymanGovernanceTestnetClient + +from tinyman.governance.constants import WEEK + +# Hardcoding account keys is not a great practice. This is for demonstration purposes only. +# See the README & Docs for alternative signing methods. +account = { + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", +} + +algod = get_algod() + +# Client +governance_client = TinymanGovernanceTestnetClient( + algod_client=algod, + user_address=account["address"] +) + +account_state = governance_client.fetch_account_state() +print("Account State before TXN:", account_state) + +new_lock_end_time = account_state.lock_end_time + 4 * WEEK +txn_group = governance_client.prepare_extend_lock_end_time_transactions( + new_lock_end_time=new_lock_end_time, +) +txn_group.sign_with_private_key(account["address"], account["private_key"]) +txn_group.submit(algod, wait=True) + +account_state = governance_client.fetch_account_state() +print("Account State after TXN:", account_state) + +tiny_power = governance_client.get_tiny_power() +print("TINY POWER:", tiny_power) + +total_tiny_power = governance_client.get_total_tiny_power() +print("Total TINY POWER:", total_tiny_power) +print(f"User TINY Power %{(tiny_power / total_tiny_power) * 100}") diff --git a/examples/governance/03_increase_lock_amount.py b/examples/governance/03_increase_lock_amount.py new file mode 100644 index 0000000..ac7d481 --- /dev/null +++ b/examples/governance/03_increase_lock_amount.py @@ -0,0 +1,36 @@ +from examples.v2.utils import get_algod +from tinyman.governance.client import TinymanGovernanceTestnetClient + +# Hardcoding account keys is not a great practice. This is for demonstration purposes only. +# See the README & Docs for alternative signing methods. +account = { + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", +} + +algod = get_algod() + +# Client +governance_client = TinymanGovernanceTestnetClient( + algod_client=algod, + user_address=account["address"] +) + +account_state = governance_client.fetch_account_state() +print("Account State before TXN:", account_state) + +txn_group = governance_client.prepare_increase_lock_amount_transactions( + locked_amount=4000000000, +) +txn_group.sign_with_private_key(account["address"], account["private_key"]) +txn_group.submit(algod, wait=True) + +account_state = governance_client.fetch_account_state() +print("Account State after TXN:", account_state) + +tiny_power = governance_client.get_tiny_power() +print("TINY POWER:", tiny_power) + +total_tiny_power = governance_client.get_total_tiny_power() +print("Total TINY POWER:", total_tiny_power) +print(f"User TINY Power %{(tiny_power / total_tiny_power) * 100}") diff --git a/examples/governance/04_increase_lock_amount_and_extend_lock_end_time.py b/examples/governance/04_increase_lock_amount_and_extend_lock_end_time.py new file mode 100644 index 0000000..08651fe --- /dev/null +++ b/examples/governance/04_increase_lock_amount_and_extend_lock_end_time.py @@ -0,0 +1,39 @@ +from examples.v2.utils import get_algod +from tinyman.governance.client import TinymanGovernanceTestnetClient + +from tinyman.governance.constants import WEEK + +# Hardcoding account keys is not a great practice. This is for demonstration purposes only. +# See the README & Docs for alternative signing methods. +account = { + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", +} + +algod = get_algod() + +# Client +governance_client = TinymanGovernanceTestnetClient( + algod_client=algod, + user_address=account["address"] +) + +account_state = governance_client.fetch_account_state() +print("Account State before TXN:", account_state) + +txn_group = governance_client.prepare_increase_lock_amount_and_extend_lock_end_time_transactions( + locked_amount=5_000_000_000, + new_lock_end_time=account_state.lock_end_time + 4 * WEEK +) +txn_group.sign_with_private_key(account["address"], account["private_key"]) +txn_group.submit(algod, wait=True) + +account_state = governance_client.fetch_account_state() +print("Account State after TXN:", account_state) + +tiny_power = governance_client.get_tiny_power() +print("TINY POWER:", tiny_power) + +total_tiny_power = governance_client.get_total_tiny_power() +print("Total TINY POWER:", total_tiny_power) +print(f"User TINY Power %{(tiny_power / total_tiny_power) * 100}") diff --git a/examples/governance/05_withdraw.py b/examples/governance/05_withdraw.py new file mode 100644 index 0000000..ec0ffee --- /dev/null +++ b/examples/governance/05_withdraw.py @@ -0,0 +1,34 @@ +from examples.v2.utils import get_algod +from tinyman.governance.client import TinymanGovernanceTestnetClient + +# Hardcoding account keys is not a great practice. This is for demonstration purposes only. +# See the README & Docs for alternative signing methods. +account = { + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", +} + +algod = get_algod() + +# Client +governance_client = TinymanGovernanceTestnetClient( + algod_client=algod, + user_address=account["address"] +) + +account_state = governance_client.fetch_account_state() +print("Account State before TXN:", account_state) + +txn_group = governance_client.prepare_withdraw_transactions() +txn_group.sign_with_private_key(account["address"], account["private_key"]) +txn_group.submit(algod, wait=True) + +account_state = governance_client.fetch_account_state() +print("Account State after TXN:", account_state) + +tiny_power = governance_client.get_tiny_power() +print("TINY POWER:", tiny_power) + +total_tiny_power = governance_client.get_total_tiny_power() +print("Total TINY POWER:", total_tiny_power) +# print(f"User TINY Power %{(tiny_power / total_tiny_power) * 100}") diff --git a/examples/governance/10_claim_reward.py b/examples/governance/10_claim_reward.py new file mode 100644 index 0000000..c028ca9 --- /dev/null +++ b/examples/governance/10_claim_reward.py @@ -0,0 +1,45 @@ +from examples.v2.utils import get_algod +from tinyman.governance.client import TinymanGovernanceTestnetClient + +from tinyman.governance.constants import TESTNET_TINY_ASSET_ID +from tinyman.governance.rewards.utils import group_adjacent_period_indexes + +# Hardcoding account keys is not a great practice. This is for demonstration purposes only. +# See the README & Docs for alternative signing methods. +account = { + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", +} + +algod = get_algod() + + +def get_tiny_balance(address): + account_info = algod.account_info(account["address"]) + assets = {a["asset-id"]: a for a in account_info["assets"]} + return assets.get(TESTNET_TINY_ASSET_ID, {}).get("amount", 0) + + +# Client +governance_client = TinymanGovernanceTestnetClient( + algod_client=algod, + user_address=account["address"] +) + +print("TINY balance before TXN:", get_tiny_balance(account["address"])) + +pending_reward_period_indexes = governance_client.get_pending_reward_period_indexes() +print(pending_reward_period_indexes) +index_groups = group_adjacent_period_indexes(pending_reward_period_indexes) + +for index_group in index_groups: + print("Index Group:", index_group) + txn_group = governance_client.prepare_claim_reward_transactions( + period_index_start=index_group[0], + period_count=len(index_group), + ) + txn_group.sign_with_private_key(account["address"], account["private_key"]) + txn_group.submit(algod, wait=True) + + account_state = governance_client.fetch_account_state() + print("TINY balance after TXN:", get_tiny_balance(account["address"])) diff --git a/examples/governance/20_staking_proposal_cast_vote.py b/examples/governance/20_staking_proposal_cast_vote.py new file mode 100644 index 0000000..d0cdb34 --- /dev/null +++ b/examples/governance/20_staking_proposal_cast_vote.py @@ -0,0 +1,32 @@ +from examples.v2.utils import get_algod +from tinyman.governance.client import TinymanGovernanceTestnetClient + +# Hardcoding account keys is not a great practice. This is for demonstration purposes only. +# See the README & Docs for alternative signing methods. +account = { + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", +} + +algod = get_algod() + +# Client +governance_client = TinymanGovernanceTestnetClient( + algod_client=algod, + user_address=account["address"], +) + +account_state = governance_client.fetch_account_state() +print("Account State before TXN:", account_state) + +proposal_id = "bafkreiamwsbjwf5ithiq67cfwecuke5k262ktxd6vacy6sz5a52nzwrc24" + +# Upload metadata +txn_group = governance_client.prepare_cast_vote_for_staking_distribution_proposal_transactions( + proposal_id=proposal_id, + votes=[20, 80], + asset_ids=[149571310, 148620458], +) +txn_group.sign_with_private_key(address=account["address"], private_key=account["private_key"]) +result = txn_group.submit(algod=algod, wait=True) +print(result) diff --git a/examples/governance/30_create_proposal.py b/examples/governance/30_create_proposal.py new file mode 100644 index 0000000..d16ab98 --- /dev/null +++ b/examples/governance/30_create_proposal.py @@ -0,0 +1,47 @@ +from examples.v2.utils import get_algod +from tinyman.governance.client import TinymanGovernanceTestnetClient +from tinyman.governance.proposal_voting.transactions import generate_proposal_metadata +from tinyman.governance.utils import generate_cid_from_proposal_metadata + +# Hardcoding account keys is not a great practice. This is for demonstration purposes only. +# See the README & Docs for alternative signing methods. +account = { + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", +} +algod = get_algod() + +# Client +governance_client = TinymanGovernanceTestnetClient( + algod_client=algod, + user_address=account["address"], +) + +account_state = governance_client.fetch_account_state() +print("Account State before TXN:", account_state) + +tiny_power = governance_client.get_tiny_power() +print("TINY POWER:", tiny_power) +total_tiny_power = governance_client.get_total_tiny_power() +print("Total TINY POWER:", total_tiny_power) +print(f"User TINY Power %{(tiny_power / total_tiny_power) * 100}") + +# Generate metadata and proposal ID +metadata = generate_proposal_metadata( + title="Proposal #3", + description="Description #3", + category="governance", + discussion_url="http://www.discussion-url.com", + poll_url="http://www.poll-url.com", +) +print(metadata) +proposal_id = generate_cid_from_proposal_metadata(metadata) + +# Upload metadata +governance_client.upload_proposal_metadata(proposal_id, metadata) + +# Submit transactions +txn_group = governance_client.prepare_create_proposal_transactions(proposal_id=proposal_id) +txn_group.sign_with_private_key(address=account["address"], private_key=account["private_key"]) +result = txn_group.submit(algod=algod, wait=True) +print(result) diff --git a/examples/governance/31_cast_vote.py b/examples/governance/31_cast_vote.py new file mode 100644 index 0000000..befffe8 --- /dev/null +++ b/examples/governance/31_cast_vote.py @@ -0,0 +1,27 @@ +from examples.v2.utils import get_algod +from tinyman.governance.client import TinymanGovernanceTestnetClient + +# Hardcoding account keys is not a great practice. This is for demonstration purposes only. +# See the README & Docs for alternative signing methods. +account = { + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", +} +algod = get_algod() + +# Client +governance_client = TinymanGovernanceTestnetClient( + algod_client=algod, + user_address=account["address"], +) + +account_state = governance_client.fetch_account_state() +print("Account State before TXN:", account_state) + +proposal_id = "bafkreicgbzr64gmjl642tazzzuomrbzn2uimhhig2wq2ch7tjcyee5cxh4" + +# Upload metadata +txn_group = governance_client.prepare_cast_vote_transactions(proposal_id=proposal_id, vote=0) +txn_group.sign_with_private_key(address=account["address"], private_key=account["private_key"]) +result = txn_group.submit(algod=algod, wait=True) +print(result) diff --git a/examples/governance/99_create_checkpoints.py b/examples/governance/99_create_checkpoints.py new file mode 100644 index 0000000..62f9197 --- /dev/null +++ b/examples/governance/99_create_checkpoints.py @@ -0,0 +1,33 @@ +from examples.v2.utils import get_algod +from tinyman.governance.client import TinymanGovernanceTestnetClient + +# Hardcoding account keys is not a great practice. This is for demonstration purposes only. +# See the README & Docs for alternative signing methods. +account = { + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", +} +algod = get_algod() + +# Client +governance_client = TinymanGovernanceTestnetClient( + algod_client=algod, + user_address=account["address"] +) + +account_state = governance_client.fetch_account_state() +print("Account State before TXN:", account_state) + +txn_group = governance_client.prepare_create_checkpoints_transactions() +txn_group.sign_with_private_key(account["address"], account["private_key"]) +txn_group.submit(algod, wait=True) + +account_state = governance_client.fetch_account_state() +print("Account State after TXN:", account_state) + +tiny_power = governance_client.get_tiny_power() +print("TINY POWER:", tiny_power) + +total_tiny_power = governance_client.get_total_tiny_power() +print("Total TINY POWER:", total_tiny_power) +print(f"User TINY Power %{(tiny_power / total_tiny_power) * 100}") diff --git a/examples/governance/__init__.py b/examples/governance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tinyman/constants.py b/tinyman/constants.py new file mode 100644 index 0000000..d94b100 --- /dev/null +++ b/tinyman/constants.py @@ -0,0 +1,17 @@ +# https://developer.algorand.org/docs/get-details/parameter_tables/ +# MaxAppTotalTxnReferences +MAX_APP_TOTAL_TXN_REFERENCES = 8 + +# MaxAppProgramCost +MAX_APP_PROGRAM_COST = 700 + +MINIMUM_BALANCE_REQUIREMENT_PER_BOX = 2_500 +MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE = 400 + +# Folks Lending Integration +MINUTE = 60 +HOUR = 60 * MINUTE +DAY = 24 * HOUR +WEEK = 7 * DAY +YEAR = 365 * DAY +HOURS_PER_YEAR = 365 * 24 diff --git a/tinyman/folks_lending/__init__.py b/tinyman/folks_lending/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tinyman/folks_lending/constants.py b/tinyman/folks_lending/constants.py new file mode 100644 index 0000000..d72483a --- /dev/null +++ b/tinyman/folks_lending/constants.py @@ -0,0 +1,5 @@ +MAINNET_FOLKS_POOL_MANAGER_APP_ID = 971350278 +TESTNET_FOLKS_POOL_MANAGER_APP_ID = 147157634 + +TESTNET_FOLKS_WRAPPER_LENDING_POOL_APP_ID = 548587153 +MAINNET_FOLKS_WRAPPER_LENDING_POOL_APP_ID = 1385499515 diff --git a/tinyman/folks_lending/transactions.py b/tinyman/folks_lending/transactions.py new file mode 100644 index 0000000..d752971 --- /dev/null +++ b/tinyman/folks_lending/transactions.py @@ -0,0 +1,167 @@ +from algosdk.encoding import decode_address +from algosdk.logic import get_application_address + +from tinyman.compat import ApplicationNoOpTxn, AssetTransferTxn, PaymentTxn, SuggestedParams +from tinyman.utils import TransactionGroup + + +def prepare_add_liquidity_transaction_group( + sender: str, + suggested_params: SuggestedParams, + wrapper_app_id: int, + tinyman_amm_app_id: int, + lending_app_1_id: int, + lending_app_2_id: int, + lending_manager_app_id: int, + tinyman_pool_address: str, + asset_1_id: int, + asset_2_id: int, + f_asset_1_id: int, + f_asset_2_id: int, + liquidity_token_id: int, + asset_1_amount: int, + asset_2_amount: int, +): + wrapper_application_address = get_application_address(wrapper_app_id) + + txns = [ + AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=wrapper_application_address, + amt=asset_1_amount, + index=asset_1_id, + ), + AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=wrapper_application_address, + amt=asset_2_amount, + index=asset_2_id, + ) if asset_2_id != 0 + else PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=wrapper_application_address, + amt=asset_2_amount, + ), + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=wrapper_app_id, + app_args=[ + b"add_liquidity", + decode_address(tinyman_pool_address), + lending_app_1_id, + lending_app_2_id, + ], + accounts=[tinyman_pool_address], + foreign_apps=[lending_app_1_id, lending_app_2_id, lending_manager_app_id], + foreign_assets=[asset_1_id, asset_2_id, f_asset_1_id, f_asset_2_id], + ), + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=wrapper_app_id, + foreign_apps=[tinyman_amm_app_id], + foreign_assets=[liquidity_token_id], + accounts=[tinyman_pool_address], + app_args=[ + b"noop", + ], + ), + ] + + min_fee = suggested_params.min_fee + txns[2].fee = min_fee * 16 + + return TransactionGroup(txns) + + +def prepare_remove_liquidity_transaction_group( + sender: int, + suggested_params: SuggestedParams, + wrapper_app_id: int, + tinyman_amm_app_id: int, + lending_app_1_id: int, + lending_app_2_id: int, + lending_manager_app_id: int, + tinyman_pool_address: str, + asset_1_id: int, + asset_2_id: int, + f_asset_1_id: int, + f_asset_2_id: int, + liquidity_token_id: int, + liquidity_token_amount: int +): + wrapper_application_address = get_application_address(wrapper_app_id) + txns = [ + AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=wrapper_application_address, + index=liquidity_token_id, + amt=liquidity_token_amount, + ), + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=wrapper_app_id, + app_args=[ + b"remove_liquidity", + decode_address(tinyman_pool_address), + lending_app_1_id, + lending_app_2_id, + ], + foreign_apps=[lending_app_1_id, lending_app_2_id, lending_manager_app_id], + foreign_assets=[asset_1_id, asset_2_id, f_asset_1_id, f_asset_2_id], + ), + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=wrapper_app_id, + app_args=[ + b"noop", + ], + foreign_apps=[tinyman_amm_app_id], + foreign_assets=[liquidity_token_id, f_asset_1_id, f_asset_2_id], + accounts=[tinyman_pool_address], + ), + ] + + min_fee = suggested_params.min_fee + txns[1].fee = min_fee * 15 + + return TransactionGroup(txns) + + +def prepare_asset_optin_transaction_group( + sender: str, + suggested_params: SuggestedParams, + wrapper_app_id: int, + assets_to_optin: list[int], +): + wrapper_application_address = get_application_address(wrapper_app_id) + txns = [ + PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=wrapper_application_address, + amt=100_000 * len(assets_to_optin), + ), + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=wrapper_app_id, + app_args=[ + b"asset_optin", + *assets_to_optin + ], + foreign_assets=assets_to_optin, + ), + ] + + min_fee = suggested_params.min_fee + txns[1].fee = min_fee * (len(assets_to_optin) + 1) + + return TransactionGroup(txns) diff --git a/tinyman/folks_lending/utils.py b/tinyman/folks_lending/utils.py new file mode 100644 index 0000000..d2308ec --- /dev/null +++ b/tinyman/folks_lending/utils.py @@ -0,0 +1,98 @@ +from base64 import b64decode, b64encode +from datetime import datetime + +from tinyman.utils import bytes_to_int +from tinyman.constants import YEAR, HOURS_PER_YEAR + + +def get_asset_pair_from_pool_app(algod, app_id): + app = algod.application_info(app_id) + global_state = {x["key"]: x["value"]["bytes"] for x in app["params"]["global-state"]} + + b = b64decode(global_state[b64encode(b"a").decode()]) + + asset_id, f_asset_id = bytes_to_int(b[:8]), bytes_to_int(b[8:16]) + return asset_id, f_asset_id + + +def get_lending_pools(algod, pool_manager_app_id): + # Get global state of lending manager app. + app = algod.application_info(pool_manager_app_id) + global_state = {x["key"]: x["value"]["bytes"] for x in app["params"]["global-state"]} + + # Concatanate all the global state values. + data = b"" + for i in range(63): + key = b64encode((i).to_bytes(1, "big")).decode() + data += b64decode(global_state[key]) # 126 bytes + + # Iterate over the data and parse. + pools = [] + for i in range(186): + pool = parse_lending_pool_info(data[(42 * i): (42 * (i + 1))]) + + if pool["pool_app_id"]: + asset_id, f_asset_id = get_asset_pair_from_pool_app(algod, pool["pool_app_id"]) + pool["asset_id"] = asset_id + pool["f_asset_id"] = f_asset_id + + pools.append(pool) + + return pools + + +def exp_by_squaring(x, n, scale): + """Returns: x**n""" + if n == 0: + return scale + + y = scale + while n > 1: + if n % 2: + y = (x * y) / scale + n = (n - 1) // 2 + else: + n = n // 2 + x = (x * x) / scale + + return int((x * y) / scale) + + +def calculate_borrow_interest_index(variable_borrow_interest_rate, old_variable_borrow_interest_index, timestamp: int): + timedelta = int(datetime.now().timestamp()) - timestamp + return int(old_variable_borrow_interest_index * exp_by_squaring(int(1e16) + variable_borrow_interest_rate / YEAR, timedelta, int(1e16)) / int(1e16)) + + +def calculate_deposit_interest_index(deposit_interest_rate, old_deposit_interest_index, timestamp): + timedelta = int(datetime.now().timestamp()) - timestamp + return int(old_deposit_interest_index * (int(1e16) + (deposit_interest_rate * timedelta) / YEAR) / int(1e16)) + + +def compound(rate, scale, period): + return exp_by_squaring(scale + (rate / period), period, scale) - scale + + +def compound_every_second(rate, scale): + return compound(rate, scale, YEAR) + + +def compound_every_hour(rate, scale): + return compound(rate, scale, HOURS_PER_YEAR) + + +def parse_lending_pool_info(pool_data) -> dict: + pool = {} + pool["pool_app_id"] = bytes_to_int(pool_data[0:6]) + pool["variable_borrow_interest_rate"] = bytes_to_int(pool_data[6:14]) + pool["old_variable_borrow_interest_index"] = bytes_to_int(pool_data[14:22]) + pool["deposit_interest_rate"] = bytes_to_int(pool_data[22:30]) + pool["old_deposit_interest_index"] = bytes_to_int(pool_data[30:38]) + pool["old_timestamp"] = bytes_to_int(pool_data[38:42]) + + pool["variable_borrow_interest_yield"] = compound_every_second(pool["variable_borrow_interest_rate"], int(1e16)) + pool["deposit_interest_yield"] = compound_every_hour(pool["deposit_interest_rate"], int(1e16)) + + pool["variable_borrow_interest_index"] = calculate_borrow_interest_index(pool["variable_borrow_interest_rate"], pool["old_variable_borrow_interest_index"], pool["old_timestamp"]) + pool["deposit_interest_index"] = calculate_deposit_interest_index(pool["deposit_interest_rate"], pool["old_deposit_interest_index"], pool["old_timestamp"]) + + return pool diff --git a/tinyman/governance/__init__.py b/tinyman/governance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tinyman/governance/client.py b/tinyman/governance/client.py new file mode 100644 index 0000000..5535d6c --- /dev/null +++ b/tinyman/governance/client.py @@ -0,0 +1,766 @@ +import time +from typing import Optional + +import requests +from algosdk.encoding import encode_address, decode_address +from algosdk.v2client.algod import AlgodClient + +from tinyman.compat import SuggestedParams +from tinyman.compat import wait_for_confirmation +from tinyman.governance.constants import TESTNET_TINY_ASSET_ID, TESTNET_VAULT_APP_ID, WEEK, TESTNET_REWARDS_APP_ID, TESTNET_STAKING_VOTING_APP_ID, TESTNET_PROPOSAL_VOTING_APP_ID, \ + MAINNET_TINY_ASSET_ID, MAINNET_VAULT_APP_ID, MAINNET_REWARDS_APP_ID, MAINNET_STAKING_VOTING_APP_ID, MAINNET_PROPOSAL_VOTING_APP_ID +from tinyman.governance.proposal_voting.constants import ACCOUNT_ATTENDANCE_SHEET_BOX_SIZE, EXECUTION_HASH_SIZE +from tinyman.governance.proposal_voting.exceptions import InsufficientTinyPower +from tinyman.governance.proposal_voting.storage import get_proposal, ProposalVotingAppGlobalState +from tinyman.governance.proposal_voting.transactions import prepare_create_proposal_transactions, prepare_cast_vote_transactions +from tinyman.governance.rewards.constants import REWARD_CLAIM_SHEET_BOX_SIZE +from tinyman.governance.rewards.storage import get_reward_histories, RewardsAppGlobalState, get_reward_history_index_at, get_reward_claim_sheet +from tinyman.governance.rewards.transactions import prepare_claim_reward_transactions, prepare_create_reward_period_transactions +from tinyman.governance.staking_voting.storage import get_staking_distribution_proposal, get_staking_attendance_sheet_box_name, StakingVotingAppGlobalState +from tinyman.governance.staking_voting.transactions import prepare_cast_vote_for_staking_distribution_proposal_transactions +from tinyman.governance.utils import get_global_state, get_all_box_names, box_exists +from tinyman.governance.vault.constants import MAX_LOCK_TIME, MIN_LOCK_TIME +from tinyman.governance.vault.exceptions import ShortLockEndTime, TooLongLockEndTime +from tinyman.governance.vault.storage import get_last_total_powers_indexes, get_power_index_at, get_account_state, get_slope_change, get_account_powers, get_all_total_powers, \ + VaultAppGlobalState +from tinyman.governance.vault.transactions import prepare_create_lock_transactions, prepare_increase_lock_amount_transactions, prepare_extend_lock_end_time_transactions, \ + prepare_create_checkpoints_transactions, prepare_withdraw_transactions +from tinyman.governance.vault.utils import get_bias, get_cumulative_power_delta, get_start_timestamp_of_week +from tinyman.optin import prepare_asset_optin_transactions +from tinyman.utils import TransactionGroup, generate_app_call_note + + +class TinymanGovernanceClient: + def __init__( + self, + algod_client: AlgodClient, + tiny_asset_id: int, + vault_app_id: int, + rewards_app_id: int, + staking_voting_app_id: int, + proposal_voting_app_id: int, + api_base_url: Optional[str] = None, + user_address: Optional[str] = None, + client_name: Optional[str] = None, + ): + self.algod = algod_client + self.tiny_asset_id = tiny_asset_id + self.vault_app_id = vault_app_id + self.rewards_app_id = rewards_app_id + self.staking_voting_app_id = staking_voting_app_id + self.proposal_voting_app_id = proposal_voting_app_id + self.api_base_url = api_base_url + self.user_address = user_address + self.client_name = client_name + + def submit(self, transaction_group, wait=False): + try: + txid = self.algod.send_transactions(transaction_group.signed_transactions) + except Exception as e: + self.handle_error(e, transaction_group) + if wait: + txn_info = wait_for_confirmation(self.algod, txid) + txn_info["txid"] = txid + return txn_info + return {"txid": txid} + + def handle_error(self, exception, transaction_group): + error_message = str(exception) + raise Exception(error_message) from None + + def prepare_asset_optin_transactions( + self, asset_id, user_address=None, suggested_params=None + ): + user_address = user_address or self.user_address + if suggested_params is None: + suggested_params = self.algod.suggested_params() + txn_group = prepare_asset_optin_transactions( + asset_id=asset_id, + sender=user_address, + suggested_params=suggested_params, + ) + return txn_group + + def asset_is_opted_in(self, asset_id, user_address=None): + user_address = user_address or self.user_address + + if asset_id == 0: + # ALGO + return True + + account_info = self.algod.account_info(user_address) + for a in account_info.get("assets", []): + if a["asset-id"] == asset_id: + return True + return False + + def generate_app_call_note(self, client_name: Optional[str] = None): + note = generate_app_call_note( + dapp_name="tinyman-governance", + version="v1", + client_name=client_name or self.client_name, + ) + return note + + def get_required_tiny_power_to_create_proposal(self) -> int: + voting_app_global_state = self.fetch_proposal_voting_app_global_state() + required_tiny_power = voting_app_global_state.proposal_threshold + + if voting_app_global_state.proposal_threshold_numerator: + total_tiny_power = self.get_total_tiny_power() + required_tiny_power = max(required_tiny_power, ((total_tiny_power * voting_app_global_state.proposal_threshold_numerator) // 100) + 1) + + return required_tiny_power + + def get_tiny_power(self, address: Optional[str] = None, timestamp: Optional[int] = None) -> int: + address = address or self.user_address + + if timestamp is None: + timestamp = int(time.time()) + + account_state = self.fetch_account_state(address) + if account_state is None: + return 0 + + account_powers = get_account_powers( + algod=self.algod, + app_id=self.vault_app_id, + address=address, + power_count=account_state.power_count, + deleted_power_count=account_state.deleted_power_count, + ) + account_power_index = get_power_index_at(account_powers, timestamp) + if account_power_index is None: + return 0 + + account_power = account_powers[account_power_index] + time_delta = timestamp - account_power.timestamp + tiny_power = max(account_power.bias - get_bias(account_power.slope, time_delta), 0) + return tiny_power + + def get_cumulative_tiny_power(self, address: Optional[str] = None, timestamp: Optional[int] = None): + address = address or self.user_address + + if timestamp is None: + timestamp = int(time.time()) + + account_state = self.fetch_account_state(address) + if account_state is None: + return 0 + + account_powers = get_account_powers( + algod=self.algod, + app_id=self.vault_app_id, + address=address, + power_count=account_state.power_count, + deleted_power_count=account_state.deleted_power_count, + ) + account_power_index = get_power_index_at(account_powers, timestamp) + if account_power_index is None: + return 0 + + account_power = account_powers[account_power_index] + time_delta = timestamp - account_power.timestamp + cumulative_power_delta = get_cumulative_power_delta(account_power.bias, account_power.slope, time_delta) + cumulative_tiny_power = account_power.cumulative_power + cumulative_power_delta + return cumulative_tiny_power + + def get_total_tiny_power(self, timestamp: Optional[int] = None): + if timestamp is None: + timestamp = int(time.time()) + + vault_app_global_state = self.fetch_vault_app_global_state() + total_powers = get_all_total_powers( + algod=self.algod, + app_id=self.vault_app_id, + total_power_count=vault_app_global_state.total_power_count + ) + total_power_index = get_power_index_at(total_powers, timestamp) + if total_power_index is None: + return 0 + + # Given timestamp can be in the future, apply slope changes + total_power = total_powers[total_power_index] + total_power_week_index = total_power.timestamp // WEEK + new_week_count = timestamp // WEEK - total_power_week_index + week_timestamps = [(total_power_week_index + i) * WEEK for i in range(1, new_week_count + 1)] + time_ranges = list(zip([total_power.timestamp] + week_timestamps, week_timestamps + [timestamp])) + + tiny_power = total_power.bias + slope = total_power.slope + + for time_range in time_ranges: + time_delta = time_range[1] - time_range[0] + bias_delta = get_bias(slope, time_delta) + tiny_power = max(tiny_power - bias_delta, 0) + slope_delta = get_slope_change(algod=self.algod, app_id=self.vault_app_id, timestamp=time_range[1]) or 0 + slope = max(slope - slope_delta, 0) + if tiny_power == 0 or slope == 0: + tiny_power = 0 + slope = 0 + + return tiny_power + + def fetch_account_state(self, user_address: Optional[str] = None): + user_address = user_address or self.user_address + + account_state = get_account_state( + algod=self.algod, + app_id=self.vault_app_id, + address=user_address + ) + return account_state + + def fetch_vault_app_global_state(self) -> VaultAppGlobalState: + data = get_global_state( + algod=self.algod, + app_id=self.vault_app_id + ) + return VaultAppGlobalState(**data) + + def fetch_rewards_app_global_state(self) -> RewardsAppGlobalState: + data = get_global_state( + algod=self.algod, + app_id=self.rewards_app_id + ) + data["manager"] = encode_address(data["manager"]) + data["rewards_manager"] = encode_address(data["rewards_manager"]) + return RewardsAppGlobalState(**data) + + def fetch_staking_voting_app_global_state(self) -> StakingVotingAppGlobalState: + data = get_global_state( + algod=self.algod, + app_id=self.staking_voting_app_id + ) + return StakingVotingAppGlobalState(**data) + + def fetch_proposal_voting_app_global_state(self) -> ProposalVotingAppGlobalState: + data = get_global_state( + algod=self.algod, + app_id=self.proposal_voting_app_id + ) + return ProposalVotingAppGlobalState(**data) + + def fetch_proposal(self, proposal_id: str): + proposal = get_proposal( + algod=self.algod, + app_id=self.proposal_voting_app_id, + proposal_id=proposal_id + ) + return proposal + + def fetch_staking_distribution_proposal(self, proposal_id: str): + proposal = get_staking_distribution_proposal( + algod=self.algod, + app_id=self.staking_voting_app_id, + proposal_id=proposal_id + ) + return proposal + + # Vault + def prepare_create_lock_transactions( + self, + locked_amount: int, + lock_end_time: int, + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.user_address + + if lock_end_time < int(time.time()) + MIN_LOCK_TIME: + raise ShortLockEndTime + + if lock_end_time > int(time.time()) + MAX_LOCK_TIME: + raise TooLongLockEndTime + + if suggested_params is None: + suggested_params = self.algod.suggested_params() + + account_state = self.fetch_account_state(user_address) + vault_app_global_state = self.fetch_vault_app_global_state() + slope_change_at_lock_end_time = get_slope_change(algod=self.algod, app_id=self.vault_app_id, timestamp=lock_end_time) + + txn_group = prepare_create_lock_transactions( + vault_app_id=self.vault_app_id, + tiny_asset_id=self.tiny_asset_id, + sender=user_address, + locked_amount=locked_amount, + lock_end_time=lock_end_time, + vault_app_global_state=vault_app_global_state, + account_state=account_state, + slope_change_at_lock_end_time=slope_change_at_lock_end_time, + suggested_params=suggested_params, + app_call_note=self.generate_app_call_note(), + ) + return txn_group + + def prepare_increase_lock_amount_transactions( + self, + locked_amount: int, + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.user_address + + if suggested_params is None: + suggested_params = self.algod.suggested_params() + + account_state = self.fetch_account_state(user_address) + vault_app_global_state = self.fetch_vault_app_global_state() + last_total_power_box_index, _ = get_last_total_powers_indexes(vault_app_global_state.total_power_count) + + txn_group = prepare_increase_lock_amount_transactions( + vault_app_id=self.vault_app_id, + tiny_asset_id=self.tiny_asset_id, + sender=user_address, + locked_amount=locked_amount, + vault_app_global_state=vault_app_global_state, + account_state=account_state, + suggested_params=suggested_params, + app_call_note=self.generate_app_call_note(), + ) + return txn_group + + def prepare_extend_lock_end_time_transactions( + self, + new_lock_end_time: int, + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.user_address + + if suggested_params is None: + suggested_params = self.algod.suggested_params() + + account_state = self.fetch_account_state(user_address) + vault_app_global_state = self.fetch_vault_app_global_state() + slope_change_at_new_lock_end_time = get_slope_change(algod=self.algod, app_id=self.vault_app_id, timestamp=new_lock_end_time) + + txn_group = prepare_extend_lock_end_time_transactions( + vault_app_id=self.vault_app_id, + sender=user_address, + new_lock_end_time=new_lock_end_time, + vault_app_global_state=vault_app_global_state, + account_state=account_state, + slope_change_at_new_lock_end_time=slope_change_at_new_lock_end_time, + suggested_params=suggested_params, + app_call_note=self.generate_app_call_note(), + ) + return txn_group + + def prepare_increase_lock_amount_and_extend_lock_end_time_transactions( + self, + locked_amount: int, + new_lock_end_time: int, + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.user_address + + if suggested_params is None: + suggested_params = self.algod.suggested_params() + + account_state = self.fetch_account_state(user_address) + vault_app_global_state = self.fetch_vault_app_global_state() + slope_change_at_new_lock_end_time = get_slope_change(algod=self.algod, app_id=self.vault_app_id, timestamp=new_lock_end_time) + + increase_lock_amount_txn_group = prepare_increase_lock_amount_transactions( + vault_app_id=self.vault_app_id, + tiny_asset_id=self.tiny_asset_id, + sender=user_address, + locked_amount=locked_amount, + vault_app_global_state=vault_app_global_state, + account_state=account_state, + suggested_params=suggested_params, + app_call_note=self.generate_app_call_note(), + ) + account_state.power_count += 1 + vault_app_global_state.total_power_count += 1 + + extend_lock_end_time_txn_group = prepare_extend_lock_end_time_transactions( + vault_app_id=self.vault_app_id, + sender=user_address, + new_lock_end_time=new_lock_end_time, + vault_app_global_state=vault_app_global_state, + account_state=account_state, + slope_change_at_new_lock_end_time=slope_change_at_new_lock_end_time, + suggested_params=suggested_params, + app_call_note=self.generate_app_call_note(), + ) + txn_group = increase_lock_amount_txn_group + extend_lock_end_time_txn_group + return txn_group + + def prepare_create_checkpoints_transactions( + self, + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.user_address + + if suggested_params is None: + suggested_params = self.algod.suggested_params() + + vault_app_global_state = self.fetch_vault_app_global_state() + txn_group = prepare_create_checkpoints_transactions( + vault_app_id=self.vault_app_id, + sender=user_address, + vault_app_global_state=vault_app_global_state, + suggested_params=suggested_params, + app_call_note=self.generate_app_call_note(), + ) + return txn_group + + def prepare_withdraw_transactions( + self, + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.user_address + + if suggested_params is None: + suggested_params = self.algod.suggested_params() + + account_state = self.fetch_account_state(user_address) + txn_group = prepare_withdraw_transactions( + vault_app_id=self.vault_app_id, + tiny_asset_id=self.tiny_asset_id, + sender=user_address, + account_state=account_state, + suggested_params=suggested_params, + app_call_note=self.generate_app_call_note(), + ) + return txn_group + + # Rewards + def prepare_create_reward_period_transactions( + self, + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.user_address + + if suggested_params is None: + suggested_params = self.algod.suggested_params() + + vault_app_global_state = self.fetch_vault_app_global_state() + rewards_app_global_state = self.fetch_rewards_app_global_state() + + total_powers = get_all_total_powers( + algod=self.algod, + app_id=self.vault_app_id, + total_power_count=vault_app_global_state.total_power_count + ) + + period_start_timestamp = rewards_app_global_state.first_period_timestamp + (rewards_app_global_state.reward_period_count * WEEK) + period_end_timestamp = period_start_timestamp + WEEK + total_power_period_start_index = get_power_index_at(total_powers, period_start_timestamp) + total_power_period_end_index = get_power_index_at(total_powers, period_end_timestamp) + + reward_histories = get_reward_histories(algod=self.algod, app_id=self.rewards_app_id, reward_history_count=rewards_app_global_state.reward_history_count) + reward_history_index = get_reward_history_index_at(reward_histories, period_start_timestamp) + + txn_group = prepare_create_reward_period_transactions( + rewards_app_id=self.rewards_app_id, + vault_app_id=self.vault_app_id, + sender=user_address, + rewards_app_global_state=rewards_app_global_state, + reward_history_index=reward_history_index, + total_power_period_start_index=total_power_period_start_index, + total_power_period_end_index=total_power_period_end_index, + suggested_params=suggested_params, + app_call_note=self.generate_app_call_note(), + ) + return txn_group + + def prepare_claim_reward_transactions( + self, + period_index_start: int, + period_count: int, + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.user_address + + if suggested_params is None: + suggested_params = self.algod.suggested_params() + + account_state = self.fetch_account_state(user_address) + rewards_app_global_state = self.fetch_rewards_app_global_state() + assert period_index_start + period_count <= rewards_app_global_state.reward_period_count + + account_powers = get_account_powers( + algod=self.algod, + app_id=self.vault_app_id, + address=user_address, + power_count=account_state.power_count, + deleted_power_count=account_state.deleted_power_count, + ) + + claim_period_start_timestamp = rewards_app_global_state.first_period_timestamp + (period_index_start * WEEK) + claim_period_end_timestamp = rewards_app_global_state.first_period_timestamp + (period_index_start + period_count) * WEEK + + account_power_indexes = [] + for timestamp in range(claim_period_start_timestamp, claim_period_end_timestamp + 1, WEEK): + account_power_index = (get_power_index_at(account_powers, timestamp) or 0) + account_power_indexes.append(account_power_index) + + create_reward_claim_sheet = False + account_reward_claim_sheet_box_indexes = { + period_index_start // (REWARD_CLAIM_SHEET_BOX_SIZE * 8), + (period_index_start + period_count) // (REWARD_CLAIM_SHEET_BOX_SIZE * 8) + } + for account_reward_claim_sheet_box_index in account_reward_claim_sheet_box_indexes: + reward_claim_sheet = get_reward_claim_sheet( + algod=self.algod, + app_id=self.rewards_app_id, + address=user_address, + account_reward_claim_sheet_box_index=account_reward_claim_sheet_box_index + ) + if reward_claim_sheet is None: + create_reward_claim_sheet = True + break + + txn_group = prepare_claim_reward_transactions( + rewards_app_id=self.rewards_app_id, + vault_app_id=self.vault_app_id, + tiny_asset_id=self.tiny_asset_id, + sender=user_address, + period_index_start=period_index_start, + period_count=period_count, + account_power_indexes=account_power_indexes, + create_reward_claim_sheet=create_reward_claim_sheet, + suggested_params=suggested_params, + app_call_note=self.generate_app_call_note(), + ) + return txn_group + + def get_pending_reward_period_indexes( + self, + user_address: Optional[str] = None, + ) -> list[int]: + user_address = user_address or self.user_address + + reward_claim_sheet = get_reward_claim_sheet( + algod=self.algod, + app_id=self.rewards_app_id, + address=user_address, + account_reward_claim_sheet_box_index=0 + ) + if reward_claim_sheet is not None: + claimed_reward_period_indexes = [index for index, value in enumerate(reward_claim_sheet.claim_sheet) if value] + else: + claimed_reward_period_indexes = [] + + rewards_app_global_state = self.fetch_rewards_app_global_state() + account_state = self.fetch_account_state(self.user_address) + account_powers = get_account_powers( + algod=self.algod, + app_id=self.vault_app_id, + address=user_address, + power_count=account_state.power_count, + deleted_power_count=account_state.deleted_power_count, + ) + + reward_period_indexes = [] + period_timestamp_min = max([account_powers[0].timestamp, rewards_app_global_state.first_period_timestamp]) + period_timestamp_max = get_start_timestamp_of_week(min([account_powers[-1].lock_end_timestamp, int(time.time())])) + + for timestamp in range(period_timestamp_min, period_timestamp_max + WEEK, WEEK): + timestamp_start = timestamp + timestamp_end = timestamp_start + WEEK + if timestamp_end > int(time.time()): + break + + index_start = get_power_index_at(account_powers, timestamp_start) + cumulative_power_start = account_powers[index_start].cumulative_power_at(timestamp_start) + + index_end = get_power_index_at(account_powers, timestamp_end) + cumulative_power_end = account_powers[index_end].cumulative_power_at(timestamp_end) + + cumulative_power_delta = cumulative_power_end - cumulative_power_start + if cumulative_power_delta: + reward_period_index = timestamp_start // WEEK - rewards_app_global_state.first_period_timestamp // WEEK + reward_period_indexes.append(reward_period_index) + + pending_period_indexes = [pi for pi in reward_period_indexes if pi not in claimed_reward_period_indexes] + return pending_period_indexes + + def prepare_create_proposal_transactions( + self, + proposal_id: str, + execution_hash: Optional[str] = None, + executor: Optional[str] = None, + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.user_address + account_tiny_power = self.get_tiny_power() + + if suggested_params is None: + suggested_params = self.algod.suggested_params() + + if required_tiny_power := self.get_required_tiny_power_to_create_proposal(): + if account_tiny_power < required_tiny_power: + raise InsufficientTinyPower() + + if executor: + executor = decode_address(executor) + else: + executor = self.fetch_proposal_voting_app_global_state().proposal_manager + + if execution_hash: + assert len(execution_hash) == EXECUTION_HASH_SIZE, "Invalid execution hash." + + vault_app_global_state = self.fetch_vault_app_global_state() + txn_group = prepare_create_proposal_transactions( + proposal_voting_app_id=self.proposal_voting_app_id, + vault_app_id=self.vault_app_id, + sender=user_address, + proposal_id=proposal_id, + vault_app_global_state=vault_app_global_state, + suggested_params=suggested_params, + app_call_note=self.generate_app_call_note(), + execution_hash=execution_hash, + executor=executor, + ) + return txn_group + + def prepare_cast_vote_transactions( + self, + proposal_id: str, + vote: int, + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.user_address + + if suggested_params is None: + suggested_params = self.algod.suggested_params() + + proposal = self.fetch_proposal(proposal_id) + account_state = self.fetch_account_state(user_address) + + assert account_state is not None + assert proposal.is_approved + assert proposal.voting_start_timestamp + assert proposal.voting_start_timestamp <= int(time.time()) <= proposal.voting_end_timestamp + + account_powers = get_account_powers( + algod=self.algod, + app_id=self.vault_app_id, + address=user_address, + power_count=account_state.power_count, + deleted_power_count=account_state.deleted_power_count, + ) + account_power_index = get_power_index_at(account_powers, proposal.creation_timestamp) + + account_attendance_sheet_box_index = proposal.index // (ACCOUNT_ATTENDANCE_SHEET_BOX_SIZE * 8) + account_attendance_sheet_box_name = get_staking_attendance_sheet_box_name(address=user_address, box_index=account_attendance_sheet_box_index) + create_attendance_sheet_box = not box_exists(self.algod, self.proposal_voting_app_id, account_attendance_sheet_box_name) + + txn_group = prepare_cast_vote_transactions( + proposal_voting_app_id=self.proposal_voting_app_id, + vault_app_id=self.vault_app_id, + sender=user_address, + proposal_id=proposal_id, + proposal=proposal, + vote=vote, + account_power_index=account_power_index, + create_attendance_sheet_box=create_attendance_sheet_box, + suggested_params=suggested_params, + app_call_note=self.generate_app_call_note(), + ) + return txn_group + + def prepare_cast_vote_for_staking_distribution_proposal_transactions( + self, + proposal_id: str, + votes: list[int], + asset_ids: list[int], + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.user_address + + if suggested_params is None: + suggested_params = self.algod.suggested_params() + + account_state = self.fetch_account_state(user_address) + staking_distribution_proposal = self.fetch_staking_distribution_proposal(proposal_id) + app_box_names = get_all_box_names(self.algod, self.staking_voting_app_id) + + account_powers = get_account_powers( + algod=self.algod, + app_id=self.vault_app_id, + address=user_address, + power_count=account_state.power_count, + deleted_power_count=account_state.deleted_power_count, + ) + account_power_index = get_power_index_at(account_powers, staking_distribution_proposal.creation_timestamp) + + txn_group = prepare_cast_vote_for_staking_distribution_proposal_transactions( + staking_voting_app_id=self.staking_voting_app_id, + vault_app_id=self.vault_app_id, + sender=user_address, + proposal_id=proposal_id, + proposal=staking_distribution_proposal, + votes=votes, + asset_ids=asset_ids, + account_power_index=account_power_index, + app_box_names=app_box_names, + suggested_params=suggested_params, + app_call_note=self.generate_app_call_note(), + ) + return txn_group + + def upload_proposal_metadata(self, proposal_id, metadata): + payload = { + "proposal_id": proposal_id, + "metadata": metadata + } + r = requests.post( + self.api_base_url + "v1/governance/proposals/", json=payload + ) + r.raise_for_status() + + +class TinymanGovernanceTestnetClient(TinymanGovernanceClient): + def __init__( + self, + algod_client: AlgodClient, + user_address: Optional[str] = None, + client_name: Optional[str] = None, + api_base_url: Optional[str] = None, + ): + super().__init__( + algod_client, + tiny_asset_id=TESTNET_TINY_ASSET_ID, + vault_app_id=TESTNET_VAULT_APP_ID, + rewards_app_id=TESTNET_REWARDS_APP_ID, + staking_voting_app_id=TESTNET_STAKING_VOTING_APP_ID, + proposal_voting_app_id=TESTNET_PROPOSAL_VOTING_APP_ID, + user_address=user_address, + client_name=client_name, + api_base_url=api_base_url or "https://testnet.analytics.tinyman.org/api/", + ) + + +class TinymanGovernanceMainnetClient(TinymanGovernanceClient): + def __init__( + self, + algod_client: AlgodClient, + user_address: Optional[str] = None, + client_name: Optional[str] = None, + api_base_url: Optional[str] = None, + ): + super().__init__( + algod_client, + tiny_asset_id=MAINNET_TINY_ASSET_ID, + vault_app_id=MAINNET_VAULT_APP_ID, + rewards_app_id=MAINNET_REWARDS_APP_ID, + staking_voting_app_id=MAINNET_STAKING_VOTING_APP_ID, + proposal_voting_app_id=MAINNET_PROPOSAL_VOTING_APP_ID, + user_address=user_address, + client_name=client_name, + api_base_url=api_base_url or "https://mainnet.analytics.tinyman.org/api/", + ) diff --git a/tinyman/governance/constants.py b/tinyman/governance/constants.py new file mode 100644 index 0000000..07e54fb --- /dev/null +++ b/tinyman/governance/constants.py @@ -0,0 +1,40 @@ +HOUR = 60 * 60 +DAY = 24 * HOUR +WEEK = 7 * DAY + +BYTES_ZERO = b'\x00' +BYTES_ONE = b'\x01' + +TESTNET_TINY_ASSET_ID = 258703304 +MAINNET_TINY_ASSET_ID = 2200000000 + +TESTNET_VAULT_APP_ID = 480164661 +MAINNET_VAULT_APP_ID = 2200606875 + +TESTNET_REWARDS_APP_ID = 336189106 +MAINNET_REWARDS_APP_ID = 2200608153 + +TESTNET_STAKING_VOTING_APP_ID = 360907790 +MAINNET_STAKING_VOTING_APP_ID = 2200609638 + +TESTNET_PROPOSAL_VOTING_APP_ID = 383416252 +MAINNET_PROPOSAL_VOTING_APP_ID = 2200608887 + +TESTNET_ARBITRARY_EXECUTOR_APP_ID = 0 # Temporary +MAINNET_ARBITRARY_EXECUTOR_APP_ID = 0 # Temporary + +TESTNET_FEE_MANAGEMENT_EXECUTOR_APP_ID = 0 # Temporary +MAINNET_FEE_MANAGEMENT_EXECUTOR_APP_ID = 0 # Temporary + +TESTNET_TREASURY_MANAGEMENT_EXECUTOR_APP_ID = 0 # Temporary +MAINNET_TREASURY_MANAGEMENT_EXECUTOR_APP_ID = 0 # Temporary + +TINY_ASSET_ID_KEY = b'tiny_asset_id' +VAULT_APP_ID_KEY = b'vault_app_id' + +INCREASE_BUDGET_APP_ARGUMENT = b"increase_budget" +GET_BOX_APP_ARGUMENT = b"get_box" +SET_MANAGER_APP_ARGUMENT = b"set_manager" +SET_PROPOSAL_MANAGER_APP_ARGUMENT = b"set_proposal_manager" +SET_VOTING_DELAY_APP_ARGUMENT = b"set_voting_delay" +SET_VOTING_DURATION_APP_ARGUMENT = b"set_voting_duration" diff --git a/tinyman/governance/event.py b/tinyman/governance/event.py new file mode 100644 index 0000000..114d155 --- /dev/null +++ b/tinyman/governance/event.py @@ -0,0 +1,82 @@ +from dataclasses import dataclass +from typing import Optional + +from Cryptodome.Hash import SHA512 +from algosdk import abi +from algosdk.abi.base_type import ABI_LENGTH_SIZE + + +@dataclass +class Event: + name: str + args: [abi.Argument] + + @property + def signature(self): + arg_string = ",".join(str(arg.type) for arg in self.args) + event_signature = "{}({})".format(self.name, arg_string) + return event_signature + + @property + def selector(self): + sha_512_256_hash = SHA512.new(truncate="256") + sha_512_256_hash.update(self.signature.encode("utf-8")) + selector = sha_512_256_hash.digest()[:4] + return selector + + def decode(self, log): + selector, event_data = log[:4], log[4:] + assert self.selector == selector + + data = { + "event_name": self.name + } + start = 0 + for arg in self.args: + if arg.type.is_dynamic(): + if isinstance(arg.type, abi.StringType): + size = int.from_bytes(event_data[start:start + ABI_LENGTH_SIZE], "big") + elif isinstance(arg.type, abi.ArrayDynamicType): + size = int.from_bytes(event_data[start:start + ABI_LENGTH_SIZE], "big") * arg.type.child_type.byte_len() + else: + raise NotImplementedError() + + end = start + ABI_LENGTH_SIZE + size + else: + end = start + arg.type.byte_len() + + value = event_data[start:end] + if isinstance(arg.type, abi.ArrayStaticType) and isinstance(arg.type.child_type, abi.ByteType): + data[arg.name] = bytes(arg.type.decode(value)) + else: + data[arg.name] = arg.type.decode(value) + start = end + return data + + def encode(self, parameters: Optional[list] = None): + log = self.selector + if parameters is None: + parameters = [] + + assert len(parameters) == len(self.args) + for parameter, arg in zip(parameters, self.args): + log += arg.type.encode(parameter) + return log + + +def get_event_by_log(log: bytes, events: list[Event]): + event_selector = log[:4] + events_filtered = [event for event in events if event.selector == event_selector] + assert len(events_filtered) == 1 + event = events_filtered[0] + return event + + +def decode_logs(logs: list[bytes], events: list[Event]): + decoded_logs = [] + + for log in logs: + event = get_event_by_log(log, events) + decoded_logs.append(event.decode(log)) + + return decoded_logs diff --git a/tinyman/governance/proposal_voting/__init__.py b/tinyman/governance/proposal_voting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tinyman/governance/proposal_voting/constants.py b/tinyman/governance/proposal_voting/constants.py new file mode 100644 index 0000000..8ce5534 --- /dev/null +++ b/tinyman/governance/proposal_voting/constants.py @@ -0,0 +1,67 @@ +from tinyman.constants import MINIMUM_BALANCE_REQUIREMENT_PER_BOX, MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE + +# Global States +PROPOSAL_INDEX_COUNTER_KEY = b'proposal_index_counter' +VOTING_DELAY_KEY = b'voting_delay' +VOTING_DURATION_KEY = b'voting_duration' +PROPOSAL_THRESHOLD_KEY = b'proposal_threshold' +PROPOSAL_THRESHOLD_NUMERATOR_KEY = b'proposal_threshold_numerator' +QUORUM_THRESHOLD_KEY = b'quorum_threshold' +MANAGER_KEY = b'manager' +PROPOSAL_MANAGER_KEY = b'proposal_manager' +APPROVAL_REQUIREMENT_KEY = b'approval_requirement' + +# Box +PROPOSAL_BOX_PREFIX = b'p' +ATTENDANCE_SHEET_BOX_PREFIX = b'a' + +PROPOSAL_BOX_SIZE = 116 + 34 + 32 +ACCOUNT_ATTENDANCE_SHEET_BOX_SIZE = 24 + +PROPOSAL_VOTING_APP_MINIMUM_BALANCE_REQUIREMENT = 100_000 + +PROPOSAL_BOX_COST = MINIMUM_BALANCE_REQUIREMENT_PER_BOX + MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE * (60 + PROPOSAL_BOX_SIZE) +ATTENDANCE_SHEET_BOX_COST = MINIMUM_BALANCE_REQUIREMENT_PER_BOX + MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE * (41 + ACCOUNT_ATTENDANCE_SHEET_BOX_SIZE) + +CREATE_PROPOSAL_APP_ARGUMENT = b"create_proposal" +CAST_VOTE_APP_ARGUMENT = b"cast_vote" +GET_PROPOSAL_APP_ARGUMENT = b"get_proposal" +GET_PROPOSAL_STATE_APP_ARGUMENT = b"get_proposal_state" +HAS_VOTED_APP_ARGUMENT = b"has_voted" +APPROVE_PROPOSAL_APP_ARGUMENT = b"approve_proposal" +CANCEL_PROPOSAL_APP_ARGUMENT = b"cancel_proposal" +EXECUTE_PROPOSAL_APP_ARGUMENT = b"execute_proposal" +DISABLE_APPROVAL_REQUIREMENT_APP_ARGUMENT = b"disable_approval_requirement" +SET_PROPOSAL_THRESHOLD_APP_ARGUMENT = b"set_proposal_threshold" +SET_PROPOSAL_THRESHOLD_NUMERATOR_APP_ARGUMENT = b"set_proposal_threshold_numerator" +SET_QUORUM_THRESHOLD_APP_ARGUMENT = b"set_quorum_threshold" + +PROPOSAL_STATE_WAITING_FOR_APPROVAL = 0 +PROPOSAL_STATE_CANCELLED = 1 +PROPOSAL_STATE_PENDING = 2 +PROPOSAL_STATE_ACTIVE = 3 +PROPOSAL_STATE_DEFEATED = 4 +PROPOSAL_STATE_SUCCEEDED = 5 +PROPOSAL_STATE_EXECUTED = 6 + +# Executors app arguments +VALIDATE_TRANSACTION_APP_ARGUMENT = b"validate_transaction" +VALIDATE_GROUP_APP_ARGUMENT = b"validate_group" +SET_FEE_SETTER_APP_ARGUMENT = b"set_fee_setter" +SET_FEE_MANAGER_APP_ARGUMENT = b"set_fee_manager" +SET_FEE_COLLECTOR_APP_ARGUMENT = b"set_fee_collector" +SET_FEE_FOR_POOL_APP_ARGUMENT = b"set_fee_for_pool" +SEND_APP_ARGUMENT = b"send" +ASSET_OPTIN_APP_ARGUMENT = b"asset_optin" + +# Executors hash prefixes +VALIDATE_TRANSACTION_HASH_PREFIX = b"vt" +VALIDATE_GROUP_HASH_PREFIX = b"vg" +SET_FEE_SETTER_HASH_PREFIX = b"fs" +SET_FEE_MANAGER_HASH_PREFIX = b"fm" +SET_FEE_COLLECTOR_HASH_PREFIX = b"fc" +SET_FEE_FOR_POOL_HASH_PREFIX = b"sf" +SEND_HASH_PREFIX = b"sn" + +EXECUTION_HASH_SIZE = 2 + 32 +CREATE_PROPOSAL_DEFAULT_EXECUTION_HASH_ARGUMENT = b"\x00" * EXECUTION_HASH_SIZE diff --git a/tinyman/governance/proposal_voting/events.py b/tinyman/governance/proposal_voting/events.py new file mode 100644 index 0000000..14ea1d2 --- /dev/null +++ b/tinyman/governance/proposal_voting/events.py @@ -0,0 +1,147 @@ +from algosdk import abi + +from tinyman.governance.event import Event + + +event_proposal = Event( + name="proposal", + args=[ + abi.Argument(arg_type="byte[59]", name="proposal_id"), + abi.Argument(arg_type="uint64", name="index"), + abi.Argument(arg_type="uint64", name="creation_timestamp"), + abi.Argument(arg_type="uint64", name="voting_start_timestamp"), + abi.Argument(arg_type="uint64", name="voting_end_timestamp"), + abi.Argument(arg_type="uint64", name="snapshot_total_voting_power"), + abi.Argument(arg_type="uint64", name="vote_count"), + abi.Argument(arg_type="uint64", name="quorum_threshold"), + abi.Argument(arg_type="uint64", name="against_voting_power"), + abi.Argument(arg_type="uint64", name="for_voting_power"), + abi.Argument(arg_type="uint64", name="abstain_voting_power"), + abi.Argument(arg_type="bool", name="is_approved"), + abi.Argument(arg_type="bool", name="is_cancelled"), + abi.Argument(arg_type="bool", name="is_executed"), + abi.Argument(arg_type="bool", name="is_quorum_reached"), + abi.Argument(arg_type="address", name="proposer_address"), + abi.Argument(arg_type="byte[34]", name="execution_hash"), + abi.Argument(arg_type="address", name="executor_address"), + ] +) + +event_approve_proposal = Event( + name="approve_proposal", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="byte[59]", name="proposal_id"), + ] +) + +event_create_proposal = Event( + name="create_proposal", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="byte[59]", name="proposal_id"), + ] +) + +event_cancel_proposal = Event( + name="cancel_proposal", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="byte[59]", name="proposal_id"), + ] +) + +event_execute_proposal = Event( + name="execute_proposal", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="byte[59]", name="proposal_id"), + ] +) + + +event_cast_vote = Event( + name="cast_vote", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="byte[59]", name="proposal_id"), + abi.Argument(arg_type="uint64", name="vote"), + abi.Argument(arg_type="uint64", name="voting_power"), + ] +) + +event_set_manager = Event( + name="set_manager", + args=[ + abi.Argument(arg_type="address", name="manager"), + ] +) + +event_set_proposal_manager = Event( + name="set_proposal_manager", + args=[ + abi.Argument(arg_type="address", name="proposal_manager"), + ] +) + +event_set_voting_delay = Event( + name="set_voting_delay", + args=[ + abi.Argument(arg_type="uint64", name="voting_delay"), + ] +) + +event_set_voting_duration = Event( + name="set_voting_duration", + args=[ + abi.Argument(arg_type="uint64", name="voting_duration"), + ] +) + + +event_set_proposal_threshold = Event( + name="set_proposal_threshold", + args=[ + abi.Argument(arg_type="uint64", name="proposal_threshold"), + ] +) + + +event_set_proposal_threshold_numerator = Event( + name="set_proposal_threshold_numerator", + args=[ + abi.Argument(arg_type="uint64", name="proposal_threshold_numerator"), + ] +) + + +event_set_quorum_threshold = Event( + name="set_quorum_threshold", + args=[ + abi.Argument(arg_type="uint64", name="quorum_threshold"), + ] +) + +event_disable_approval_requirement = Event( + name="disable_approval_requirement", + args=[] +) + +proposal_voting_events = [ + # method calls + event_create_proposal, + event_approve_proposal, + event_cancel_proposal, + event_execute_proposal, + event_cast_vote, + event_set_manager, + event_set_proposal_manager, + event_set_voting_delay, + event_set_voting_duration, + event_set_proposal_threshold, + event_set_proposal_threshold_numerator, + event_set_quorum_threshold, + event_disable_approval_requirement, + # boxes + event_proposal, +] diff --git a/tinyman/governance/proposal_voting/exceptions.py b/tinyman/governance/proposal_voting/exceptions.py new file mode 100644 index 0000000..838c3f8 --- /dev/null +++ b/tinyman/governance/proposal_voting/exceptions.py @@ -0,0 +1,2 @@ +class InsufficientTinyPower(Exception): + pass diff --git a/tinyman/governance/proposal_voting/executor_transactions.py b/tinyman/governance/proposal_voting/executor_transactions.py new file mode 100644 index 0000000..a018fb6 --- /dev/null +++ b/tinyman/governance/proposal_voting/executor_transactions.py @@ -0,0 +1,292 @@ +from base64 import b32decode, b64decode +from hashlib import sha256 + +from algosdk import transaction +from algosdk.encoding import _correct_padding, decode_address +from algosdk.logic import get_application_address + +from tinyman.compat import SuggestedParams +from tinyman.governance.proposal_voting.constants import ( + ASSET_OPTIN_APP_ARGUMENT, + SEND_APP_ARGUMENT, + SEND_HASH_PREFIX, + SET_FEE_COLLECTOR_APP_ARGUMENT, + SET_FEE_COLLECTOR_HASH_PREFIX, + SET_FEE_FOR_POOL_APP_ARGUMENT, + SET_FEE_FOR_POOL_HASH_PREFIX, + SET_FEE_MANAGER_APP_ARGUMENT, + SET_FEE_MANAGER_HASH_PREFIX, + SET_FEE_SETTER_APP_ARGUMENT, + SET_FEE_SETTER_HASH_PREFIX, + VALIDATE_GROUP_APP_ARGUMENT, + VALIDATE_GROUP_HASH_PREFIX, + VALIDATE_TRANSACTION_APP_ARGUMENT, + VALIDATE_TRANSACTION_HASH_PREFIX, +) +from tinyman.governance.proposal_voting.storage import get_proposal_box_name +from tinyman.utils import TransactionGroup, int_to_bytes + + +def get_arbitrary_transaction_execution_hash(txn: transaction.Transaction): + execution_hash = b32decode(_correct_padding(txn.get_txid())) + execution_hash = VALIDATE_TRANSACTION_HASH_PREFIX + execution_hash + return execution_hash + + +def get_arbitrary_transaction_group_execution_hash(txn_group: TransactionGroup): + execution_hash = b64decode(txn_group.id) + execution_hash = VALIDATE_GROUP_HASH_PREFIX + execution_hash + return execution_hash + + +def prepare_validate_transaction_transactions( + arbitrary_executor_app_id: int, + proposal_voting_app_id: int, + proposal_id: str, + transaction_to_validate: transaction.Transaction, + sender: str, + suggested_params: SuggestedParams, +): + executor_transaction = transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=arbitrary_executor_app_id, + app_args=[VALIDATE_TRANSACTION_APP_ARGUMENT, proposal_id], + foreign_apps=[proposal_voting_app_id], + boxes=[(proposal_voting_app_id, get_proposal_box_name(proposal_id))], + ) + txn_group = TransactionGroup([executor_transaction, transaction_to_validate]) + return txn_group + + +def prepare_validate_group_transactions( + arbitrary_executor_app_id: int, + proposal_voting_app_id: int, + proposal_id: str, + group_to_validate: TransactionGroup, + sender: str, + suggested_params: SuggestedParams, +): + executor_transaction = transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=arbitrary_executor_app_id, + app_args=[VALIDATE_GROUP_APP_ARGUMENT, proposal_id], + foreign_apps=[proposal_voting_app_id], + boxes=[(proposal_voting_app_id, get_proposal_box_name(proposal_id))], + ) + txn_group = TransactionGroup([executor_transaction] + group_to_validate.transactions) + return txn_group + + +def get_set_fee_setter_transactions_execution_hash(new_fee_setter: str): + execution_hash = bytes("set_fee_setter", "utf-8") + decode_address(new_fee_setter) + execution_hash = sha256(execution_hash).digest() + execution_hash = SET_FEE_SETTER_HASH_PREFIX + execution_hash + + return execution_hash + + +def prepare_set_fee_setter_transactions( + fee_management_executor_app_id: int, + proposal_voting_app_id: int, + amm_app_id: str, + proposal_id: str, + new_fee_setter: str, + sender: str, + suggested_params: SuggestedParams, +): + executor_transaction = transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=fee_management_executor_app_id, + app_args=[SET_FEE_SETTER_APP_ARGUMENT, proposal_id, decode_address(new_fee_setter)], + accounts=[new_fee_setter], + foreign_apps=[proposal_voting_app_id, amm_app_id], + boxes=[(proposal_voting_app_id, get_proposal_box_name(proposal_id))], + ) + + txn_group = TransactionGroup([executor_transaction]) + return txn_group + + +def get_set_fee_manager_transactions_execution_hash(new_fee_manager: str): + execution_hash = bytes("set_fee_manager", "utf-8") + decode_address(new_fee_manager) + execution_hash = sha256(execution_hash).digest() + execution_hash = SET_FEE_MANAGER_HASH_PREFIX + execution_hash + + return execution_hash + + +def prepare_set_fee_manager_transactions( + fee_management_executor_app_id: int, + proposal_voting_app_id: int, + amm_app_id: str, + proposal_id: str, + new_fee_manager: str, + sender: str, + suggested_params: SuggestedParams, +): + executor_transaction = transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=fee_management_executor_app_id, + app_args=[SET_FEE_MANAGER_APP_ARGUMENT, proposal_id, decode_address(new_fee_manager)], + accounts=[new_fee_manager], + foreign_apps=[proposal_voting_app_id, amm_app_id], + boxes=[(proposal_voting_app_id, get_proposal_box_name(proposal_id))], + ) + + txn_group = TransactionGroup([executor_transaction]) + return txn_group + + +def get_set_fee_collector_transactions_execution_hash(new_fee_collector: str): + execution_hash = bytes("set_fee_collector", "utf-8") + decode_address(new_fee_collector) + execution_hash = sha256(execution_hash).digest() + execution_hash = SET_FEE_COLLECTOR_HASH_PREFIX + execution_hash + + return execution_hash + + +def prepare_set_fee_collector_transactions( + fee_management_executor_app_id: int, + proposal_voting_app_id: int, + amm_app_id: str, + proposal_id: str, + new_fee_collector: str, + sender: str, + suggested_params: SuggestedParams, +): + executor_transaction = transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=fee_management_executor_app_id, + app_args=[SET_FEE_COLLECTOR_APP_ARGUMENT, proposal_id, decode_address(new_fee_collector)], + accounts=[new_fee_collector], + foreign_apps=[proposal_voting_app_id, amm_app_id], + boxes=[(proposal_voting_app_id, get_proposal_box_name(proposal_id))], + ) + + txn_group = TransactionGroup([executor_transaction]) + return txn_group + + +def get_set_fee_for_pool_transactions_execution_hash( + pool_address: str, pool_total_fee_share: int, pool_protocol_fee_ratio: int +): + execution_hash = ( + bytes("set_fee_for_pool", "utf-8") + + decode_address(pool_address) + + int_to_bytes(pool_total_fee_share) + + int_to_bytes(pool_protocol_fee_ratio) + ) + execution_hash = sha256(execution_hash).digest() + execution_hash = SET_FEE_FOR_POOL_HASH_PREFIX + execution_hash + + return execution_hash + + +def prepare_set_fee_for_pool_transactions( + fee_management_executor_app_id: int, + proposal_voting_app_id: int, + amm_app_id: str, + proposal_id: str, + pool_address: str, + pool_total_fee_share: int, + pool_protocol_fee_ratio: int, + sender: str, + suggested_params: SuggestedParams, +): + executor_transaction = transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=fee_management_executor_app_id, + app_args=[ + SET_FEE_FOR_POOL_APP_ARGUMENT, + proposal_id, + decode_address(pool_address), + int_to_bytes(pool_total_fee_share), + int_to_bytes(pool_protocol_fee_ratio), + ], + accounts=[pool_address], + foreign_apps=[proposal_voting_app_id, amm_app_id], + boxes=[(proposal_voting_app_id, get_proposal_box_name(proposal_id))], + ) + + txn_group = TransactionGroup([executor_transaction]) + return txn_group + + +def get_send_transactions_execution_hash(treasury_account: str, receiver: str, amount: int, asset_id: int): + execution_hash = ( + bytes("send", "utf-8") + + decode_address(treasury_account) + + decode_address(receiver) + + int_to_bytes(amount) + + int_to_bytes(asset_id) + ) + execution_hash = sha256(execution_hash).digest() + execution_hash = SEND_HASH_PREFIX + execution_hash + + return execution_hash + + +def prepare_send_transactions( + treasury_management_executor_app_id: int, + proposal_voting_app_id: int, + proposal_id: str, + treasury_account: str, + receiver: str, + asset_id: int, + amount: int, + sender: str, + suggested_params: SuggestedParams, +): + send_transaction = transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=treasury_management_executor_app_id, + app_args=[ + SEND_APP_ARGUMENT, + proposal_id, + decode_address(treasury_account), + decode_address(receiver), + amount, + asset_id, + ], + foreign_apps=[proposal_voting_app_id], + boxes=[(proposal_voting_app_id, get_proposal_box_name(proposal_id))], + accounts=[treasury_account, receiver], + ) + txn_group = TransactionGroup([send_transaction]) + return txn_group + + +def prepare_asset_optin_transactions( + sender: str, + suggested_params: SuggestedParams, + app_id: int, + asset_id: int, +): + txns = [ + transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(app_id), + amt=100000, + ), + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=app_id, + app_args=[ASSET_OPTIN_APP_ARGUMENT, int_to_bytes(asset_id)], + foreign_assets=[asset_id], + ), + ] + + # 1 inner transaction + txns[1].fee *= 2 + + txn_group = TransactionGroup(txns) + return txn_group diff --git a/tinyman/governance/proposal_voting/storage.py b/tinyman/governance/proposal_voting/storage.py new file mode 100644 index 0000000..5cf618d --- /dev/null +++ b/tinyman/governance/proposal_voting/storage.py @@ -0,0 +1,109 @@ +import time +from dataclasses import dataclass +from typing import Optional +from algosdk.encoding import decode_address, encode_address + +from tinyman.governance.proposal_voting.constants import PROPOSAL_BOX_PREFIX, ATTENDANCE_SHEET_BOX_PREFIX, PROPOSAL_STATE_CANCELLED, PROPOSAL_STATE_EXECUTED, PROPOSAL_STATE_WAITING_FOR_APPROVAL, PROPOSAL_STATE_PENDING, PROPOSAL_STATE_ACTIVE, PROPOSAL_STATE_DEFEATED, PROPOSAL_STATE_SUCCEEDED +from tinyman.governance.utils import get_raw_box_value +from tinyman.utils import int_to_bytes, bytes_to_int + + +@dataclass +class ProposalVotingAppGlobalState: + vault_app_id: int + proposal_index_counter: int + voting_delay: int + voting_duration: int + proposal_threshold: int + proposal_threshold_numerator: int + quorum_threshold: int + approval_requirement: int + manager: str + proposal_manager: str + + +@dataclass +class Proposal: + index: int + creation_timestamp: int + voting_start_timestamp: int + voting_end_timestamp: int + snapshot_total_voting_power: int + vote_count: int + quorum_threshold: int + against_voting_power: int + for_voting_power: int + abstain_voting_power: int + is_approved: bool + is_cancelled: bool + is_executed: bool + is_quorum_reached: bool + proposer_address: str + execution_hash: str + executor_address: str + + @property + def snapshot_timestamp(self) -> int: + return self.creation_timestamp + + @property + def is_vote_succeeded(self) -> bool: + return self.for_voting_power > self.against_voting_power + + @property + def state(self): + now = int(time.time()) + + if self.is_cancelled: + return PROPOSAL_STATE_CANCELLED + elif self.is_executed: + return PROPOSAL_STATE_EXECUTED + elif not self.voting_start_timestamp: + return PROPOSAL_STATE_WAITING_FOR_APPROVAL + elif now < self.voting_start_timestamp: + return PROPOSAL_STATE_PENDING + elif now < self.voting_end_timestamp: + return PROPOSAL_STATE_ACTIVE + elif not self.is_quorum_reached or not self.is_vote_succeeded: + return PROPOSAL_STATE_DEFEATED + else: + return PROPOSAL_STATE_SUCCEEDED + + +def get_proposal_box_name(proposal_id: str) -> bytes: + return PROPOSAL_BOX_PREFIX + proposal_id.encode() + + +def get_attendance_sheet_box_name(address: str, box_index: int) -> bytes: + return ATTENDANCE_SHEET_BOX_PREFIX + decode_address(address) + int_to_bytes(box_index) + + +def parse_box_proposal(raw_box) -> Proposal: + proposal = Proposal( + index=bytes_to_int(raw_box[:8]), + creation_timestamp=bytes_to_int(raw_box[8:16]), + voting_start_timestamp=bytes_to_int(raw_box[16:24]), + voting_end_timestamp=bytes_to_int(raw_box[24:32]), + snapshot_total_voting_power=bytes_to_int(raw_box[32:40]), + vote_count=bytes_to_int(raw_box[40:48]), + quorum_threshold=bytes_to_int(raw_box[48:56]), + against_voting_power=bytes_to_int(raw_box[56:64]), + for_voting_power=bytes_to_int(raw_box[64:72]), + abstain_voting_power=bytes_to_int(raw_box[72:80]), + is_approved=bool(bytes_to_int(raw_box[80:81])), + is_cancelled=bool(bytes_to_int(raw_box[81:82])), + is_executed=bool(bytes_to_int(raw_box[82:83])), + is_quorum_reached=bool(bytes_to_int(raw_box[83:84])), + proposer_address=encode_address(raw_box[84:116]), + execution_hash=raw_box[116:150], + executor_address=encode_address(raw_box[150:182]), + ) + return proposal + + +def get_proposal(algod, app_id: int, proposal_id: str) -> Optional[Proposal]: + box_name = get_proposal_box_name(proposal_id) + raw_box = get_raw_box_value(algod, app_id, box_name) + if not raw_box: + return None + return parse_box_proposal(raw_box) diff --git a/tinyman/governance/proposal_voting/transactions.py b/tinyman/governance/proposal_voting/transactions.py new file mode 100644 index 0000000..5805581 --- /dev/null +++ b/tinyman/governance/proposal_voting/transactions.py @@ -0,0 +1,403 @@ +from typing import Optional + +from algosdk import transaction +from algosdk.constants import ZERO_ADDRESS +from algosdk.encoding import decode_address +from algosdk.logic import get_application_address + +from tinyman.compat import SuggestedParams +from tinyman.governance.proposal_voting.constants import PROPOSAL_BOX_COST, ACCOUNT_ATTENDANCE_SHEET_BOX_SIZE, ATTENDANCE_SHEET_BOX_COST, CREATE_PROPOSAL_APP_ARGUMENT, \ + CAST_VOTE_APP_ARGUMENT, GET_PROPOSAL_APP_ARGUMENT, HAS_VOTED_APP_ARGUMENT, APPROVE_PROPOSAL_APP_ARGUMENT, CANCEL_PROPOSAL_APP_ARGUMENT, EXECUTE_PROPOSAL_APP_ARGUMENT, \ + DISABLE_APPROVAL_REQUIREMENT_APP_ARGUMENT, SET_PROPOSAL_THRESHOLD_APP_ARGUMENT, SET_QUORUM_THRESHOLD_APP_ARGUMENT, GET_PROPOSAL_STATE_APP_ARGUMENT, SET_PROPOSAL_THRESHOLD_NUMERATOR_APP_ARGUMENT, \ + CREATE_PROPOSAL_DEFAULT_EXECUTION_HASH_ARGUMENT +from tinyman.governance.proposal_voting.storage import Proposal +from tinyman.governance.proposal_voting.storage import get_proposal_box_name, get_attendance_sheet_box_name +from tinyman.governance.transactions import _prepare_set_manager_transactions, _prepare_set_proposal_manager_transactions, _prepare_set_voting_delay_transactions, \ + _prepare_set_voting_duration_transactions +from tinyman.governance.vault.constants import ACCOUNT_POWER_BOX_ARRAY_LEN +from tinyman.governance.vault.storage import VaultAppGlobalState, get_account_state_box_name, get_account_power_box_name +from tinyman.governance.vault.storage import get_total_power_box_name +from tinyman.utils import TransactionGroup + + +def generate_proposal_metadata( + title: str, + description: str, + category: str, + discussion_url: str, + poll_url: str, +): + # keys are sorted. + metadata = dict( + category=category, + description=description, + discussion_url=discussion_url, + poll_url=poll_url, + title=title, + ) + for key, value in metadata.items(): + metadata[key] = value.strip() + return metadata + + +def prepare_create_proposal_transactions( + proposal_voting_app_id: int, + vault_app_id: int, + sender: str, + proposal_id: str, + vault_app_global_state: VaultAppGlobalState, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, + execution_hash: Optional[str] = None, + executor: Optional[str] = None, +): + proposal_box_name = get_proposal_box_name(proposal_id) + account_state_box_name = get_account_state_box_name(address=sender) + last_total_power_box_name = get_total_power_box_name(box_index=vault_app_global_state.last_total_power_box_index) + + txns = [ + transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(proposal_voting_app_id), + amt=PROPOSAL_BOX_COST, + ), + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=proposal_voting_app_id, + app_args=[CREATE_PROPOSAL_APP_ARGUMENT, proposal_id, execution_hash or CREATE_PROPOSAL_DEFAULT_EXECUTION_HASH_ARGUMENT, executor or decode_address(ZERO_ADDRESS)], + foreign_apps=[vault_app_id], + boxes=[ + (proposal_voting_app_id, proposal_box_name), + (vault_app_id, account_state_box_name), + (vault_app_id, last_total_power_box_name) + ], + note=app_call_note + ) + ] + + # 2 inner txns + txns[1].fee *= 3 + return TransactionGroup(txns) + + +def prepare_cast_vote_transactions( + proposal_voting_app_id: int, + vault_app_id: int, + sender: str, + proposal_id: str, + proposal: Proposal, + vote: int, + account_power_index: int, + create_attendance_sheet_box: bool, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + assert vote in [0, 1, 2] + + account_power_box_index = account_power_index // ACCOUNT_POWER_BOX_ARRAY_LEN + account_attendance_box_index = proposal.index // (ACCOUNT_ATTENDANCE_SHEET_BOX_SIZE * 8) + boxes = [ + (proposal_voting_app_id, get_proposal_box_name(proposal_id)), + (proposal_voting_app_id, get_attendance_sheet_box_name(sender, account_attendance_box_index)), + (vault_app_id, get_account_state_box_name(address=sender)), + (vault_app_id, get_account_power_box_name(address=sender, box_index=account_power_box_index)), + (vault_app_id, get_account_power_box_name(address=sender, box_index=account_power_box_index + 1)), + ] + + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=proposal_voting_app_id, + app_args=[CAST_VOTE_APP_ARGUMENT, proposal_id, vote, account_power_index], + foreign_apps=[vault_app_id], + boxes=boxes, + note=app_call_note + ), + ] + # 1 inner txn + txns[0].fee *= 2 + + if create_attendance_sheet_box: + minimum_balance_payment = transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(proposal_voting_app_id), + amt=ATTENDANCE_SHEET_BOX_COST, + ) + txns.insert(0, minimum_balance_payment) + + return TransactionGroup(txns) + + +def prepare_get_proposal_transactions( + proposal_voting_app_id: int, + sender: str, + proposal_id: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + proposal_box_name = get_proposal_box_name(proposal_id) + + boxes = [ + (proposal_voting_app_id, proposal_box_name), + ] + txn_group = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=proposal_voting_app_id, + app_args=[GET_PROPOSAL_APP_ARGUMENT, proposal_id], + boxes=boxes, + note=app_call_note + ), + ] + return TransactionGroup(txn_group) + + +def prepare_get_proposal_state_transactions( + proposal_voting_app_id: int, + sender: str, + proposal_id: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + proposal_box_name = get_proposal_box_name(proposal_id) + + boxes = [ + (proposal_voting_app_id, proposal_box_name), + ] + txn_group = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=proposal_voting_app_id, + app_args=[GET_PROPOSAL_STATE_APP_ARGUMENT, proposal_id], + boxes=boxes, + note=app_call_note + ), + ] + return TransactionGroup(txn_group) + + +def prepare_has_voted_transactions( + proposal_voting_app_id: int, + sender: str, + proposal_id: str, + proposal: Proposal, + suggested_params: SuggestedParams, + address_to_check: Optional[str] = None, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + if address_to_check is None: + address_to_check = sender + + proposal_box_name = get_proposal_box_name(proposal_id) + account_attendance_box_index = proposal.index // (ACCOUNT_ATTENDANCE_SHEET_BOX_SIZE * 8) + + boxes = [ + (proposal_voting_app_id, proposal_box_name), + (proposal_voting_app_id, get_attendance_sheet_box_name(address_to_check, account_attendance_box_index)), + ] + + txn_group = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=proposal_voting_app_id, + app_args=[HAS_VOTED_APP_ARGUMENT, proposal_id, decode_address(address_to_check)], + boxes=boxes, + note=app_call_note + ), + ] + return TransactionGroup(txn_group) + + +def prepare_approve_proposal_transactions( + proposal_voting_app_id: int, + sender: str, + proposal_id: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + proposal_box_name = get_proposal_box_name(proposal_id) + boxes = [ + (proposal_voting_app_id, proposal_box_name), + ] + txn_group = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=proposal_voting_app_id, + app_args=[APPROVE_PROPOSAL_APP_ARGUMENT, proposal_id], + boxes=boxes, + note=app_call_note + ) + ] + return TransactionGroup(txn_group) + + +def prepare_cancel_proposal_transactions( + proposal_voting_app_id: int, + sender: str, + proposal_id: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + proposal_box_name = get_proposal_box_name(proposal_id) + boxes = [ + (proposal_voting_app_id, proposal_box_name), + ] + txn_group = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=proposal_voting_app_id, + app_args=[CANCEL_PROPOSAL_APP_ARGUMENT, proposal_id], + boxes=boxes, + note=app_call_note + ) + ] + return TransactionGroup(txn_group) + + +def prepare_execute_proposal_transactions( + proposal_voting_app_id: int, + sender: str, + proposal_id: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + proposal_box_name = get_proposal_box_name(proposal_id) + boxes = [ + (proposal_voting_app_id, proposal_box_name), + ] + txn_group = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=proposal_voting_app_id, + app_args=[EXECUTE_PROPOSAL_APP_ARGUMENT, proposal_id], + boxes=boxes, + note=app_call_note + ) + ] + return TransactionGroup(txn_group) + + +def prepare_set_manager_transactions( + proposal_voting_app_id: int, + **kwargs +): + # Only proposal manager can call this method. + return _prepare_set_manager_transactions(app_id=proposal_voting_app_id, **kwargs) + + +def prepare_set_proposal_manager_transactions( + proposal_voting_app_id: int, + **kwargs +): + # Only manager can call this method. + return _prepare_set_proposal_manager_transactions(app_id=proposal_voting_app_id, **kwargs) + + +def prepare_disable_approval_requirement_transactions( + proposal_voting_app_id: int, + sender: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None +): + # Only manager can call this method. + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=proposal_voting_app_id, + app_args=[DISABLE_APPROVAL_REQUIREMENT_APP_ARGUMENT], + note=app_call_note + ), + ] + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_set_voting_delay_transactions( + proposal_voting_app_id: int, + **kwargs +): + # Only manager can call this method. + return _prepare_set_voting_delay_transactions(app_id=proposal_voting_app_id, **kwargs) + + +def prepare_set_voting_duration_transactions( + proposal_voting_app_id: int, + **kwargs +): + # Only manager can call this method. + return _prepare_set_voting_duration_transactions(app_id=proposal_voting_app_id, **kwargs) + + +def prepare_set_proposal_threshold_transactions( + proposal_voting_app_id: int, + sender: str, + new_proposal_threshold: int, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None +): + # Only manager can call this method. + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=proposal_voting_app_id, + app_args=[SET_PROPOSAL_THRESHOLD_APP_ARGUMENT, new_proposal_threshold], + note=app_call_note + ), + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_set_proposal_threshold_numerator_transactions( + proposal_voting_app_id: int, + sender: str, + new_proposal_threshold_numerator: int, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None +): + # Only manager can call this method. + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=proposal_voting_app_id, + app_args=[SET_PROPOSAL_THRESHOLD_NUMERATOR_APP_ARGUMENT, new_proposal_threshold_numerator], + note=app_call_note + ), + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_set_quorum_threshold_transactions( + proposal_voting_app_id: int, + sender: str, + new_quorum_threshold: int, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None +): + # Only manager can call this method. + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=proposal_voting_app_id, + app_args=[SET_QUORUM_THRESHOLD_APP_ARGUMENT, new_quorum_threshold], + note=app_call_note + ), + ] + txn_group = TransactionGroup(txns) + return txn_group diff --git a/tinyman/governance/rewards/__init__.py b/tinyman/governance/rewards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tinyman/governance/rewards/constants.py b/tinyman/governance/rewards/constants.py new file mode 100644 index 0000000..e441cf8 --- /dev/null +++ b/tinyman/governance/rewards/constants.py @@ -0,0 +1,38 @@ +# Global states +from tinyman.constants import MINIMUM_BALANCE_REQUIREMENT_PER_BOX, MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE + +FIRST_PERIOD_TIMESTAMP = b'first_period_timestamp' +REWARD_HISTORY_COUNT_KEY = b'reward_history_count' +REWARD_PERIOD_COUNT_KEY = b'reward_period_count' +MANAGER_KEY = b'manager' +REWARDS_MANAGER_KEY = b'rewards_manager' + +# Boxes +REWARD_PERIOD_BOX_PREFIX = b'rp' +REWARD_HISTORY_BOX_PREFIX = b'rh' +REWARD_CLAIM_SHEET_BOX_PREFIX = b'c' + +REWARD_CLAIM_SHEET_BOX_SIZE = 1012 + +REWARD_HISTORY_SIZE = 16 +REWARD_HISTORY_BOX_SIZE = 256 +REWARD_HISTORY_BOX_ARRAY_LEN = 16 + +REWARD_PERIOD_SIZE = 24 +REWARD_PERIOD_BOX_SIZE = 1008 +REWARD_PERIOD_BOX_ARRAY_LEN = 42 + +REWARD_CLAIM_SHEET_BOX_COST = MINIMUM_BALANCE_REQUIREMENT_PER_BOX + MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE * (41 + REWARD_CLAIM_SHEET_BOX_SIZE) +REWARD_PERIOD_BOX_COST = MINIMUM_BALANCE_REQUIREMENT_PER_BOX + MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE * (10 + REWARD_PERIOD_BOX_SIZE) +REWARD_HISTORY_BOX_COST = MINIMUM_BALANCE_REQUIREMENT_PER_BOX + MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE * (10 + REWARD_HISTORY_BOX_SIZE) + +# 100_000 Default +# 100_000 Opt-in +# Box +REWARDS_APP_MINIMUM_BALANCE_REQUIREMENT = 200_000 + REWARD_HISTORY_BOX_COST + +INIT_APP_ARGUMENT = b"init" +SET_REWARD_AMOUNT_APP_ARGUMENT = b"set_reward_amount" +CREATE_REWARD_PERIOD_APP_ARGUMENT = b"create_reward_period" +CLAIM_REWARDS_APP_ARGUMENT = b"claim_rewards" +SET_REWARDS_MANAGER_APP_ARGUMENT = b"set_rewards_manager" diff --git a/tinyman/governance/rewards/events.py b/tinyman/governance/rewards/events.py new file mode 100644 index 0000000..95f5aff --- /dev/null +++ b/tinyman/governance/rewards/events.py @@ -0,0 +1,86 @@ +from algosdk import abi + +from tinyman.governance.event import Event + + +event_init = Event( + name="init", + args=[ + abi.Argument(arg_type="uint64", name="first_period_timestamp"), + abi.Argument(arg_type="uint64", name="reward_amount"), + ] +) + +event_set_reward_amount = Event( + name="set_reward_amount", + args=[ + abi.Argument(arg_type="uint64", name="timestamp"), + abi.Argument(arg_type="uint64", name="reward_amount"), + ] +) + +event_create_reward_period = Event( + name="create_reward_period", + args=[ + abi.Argument(arg_type="uint64", name="index"), + abi.Argument(arg_type="uint64", name="total_reward_amount"), + abi.Argument(arg_type="uint128", name="total_cumulative_power_delta"), + ] +) + +event_claim_rewards = Event( + name="claim_rewards", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="uint64", name="total_reward_amount"), + abi.Argument(arg_type="uint64", name="period_index_start"), + abi.Argument(arg_type="uint64", name="period_count"), + abi.Argument(arg_type="uint64[]", name="reward_amounts"), + ] +) + +event_set_manager = Event( + name="set_manager", + args=[ + abi.Argument(arg_type="address", name="manager"), + ] +) + +event_set_rewards_manager = Event( + name="set_rewards_manager", + args=[ + abi.Argument(arg_type="address", name="rewards_manager"), + ] +) + +event_reward_period = Event( + name="reward_period", + args=[ + abi.Argument(arg_type="uint64", name="index"), + abi.Argument(arg_type="uint64", name="total_reward_amount"), + abi.Argument(arg_type="uint128", name="total_cumulative_power_delta"), + ] +) + +event_reward_history = Event( + name="reward_history", + args=[ + abi.Argument(arg_type="uint64", name="index"), + abi.Argument(arg_type="uint64", name="timestamp"), + abi.Argument(arg_type="uint64", name="reward_amount"), + ] +) + + +rewards_events = [ + # method calls + event_init, + event_set_reward_amount, + event_claim_rewards, + event_create_reward_period, + event_set_manager, + event_set_rewards_manager, + # boxes + event_reward_period, + event_reward_history, +] diff --git a/tinyman/governance/rewards/storage.py b/tinyman/governance/rewards/storage.py new file mode 100644 index 0000000..db3b7f4 --- /dev/null +++ b/tinyman/governance/rewards/storage.py @@ -0,0 +1,142 @@ +import math +from dataclasses import dataclass +from typing import Optional + +from tinyman.governance.constants import WEEK +from tinyman.governance.rewards.constants import REWARD_HISTORY_BOX_PREFIX, REWARD_HISTORY_BOX_ARRAY_LEN, REWARD_HISTORY_SIZE, REWARD_CLAIM_SHEET_BOX_PREFIX, REWARD_PERIOD_BOX_PREFIX, REWARD_PERIOD_BOX_ARRAY_LEN, REWARD_PERIOD_SIZE +from tinyman.governance.utils import get_raw_box_value, check_nth_bit_from_left +from tinyman.utils import int_to_bytes, bytes_to_int +from algosdk.encoding import decode_address + + +@dataclass +class RewardsAppGlobalState: + tiny_asset_id: int + vault_app_id: int + reward_history_count: int + reward_period_count: int + first_period_timestamp: int + manager: str + rewards_manager: str + + def get_reward_period_index(self, timestamp): + reward_period_index = timestamp // WEEK - self.first_period_timestamp // WEEK + return reward_period_index + + @property + def free_reward_history_space_count(self) -> int: + remainder = self.reward_history_count % REWARD_HISTORY_BOX_ARRAY_LEN + return REWARD_HISTORY_BOX_ARRAY_LEN - remainder if remainder > 0 else 0 + + +@dataclass +class RewardPeriod: + total_reward_amount: int + total_cumulative_power_delta: int + + +@dataclass +class RewardHistory: + timestamp: int + reward_amount: int + + +@dataclass +class RewardClaimSheet: + value: bytes + + @property + def claim_sheet(self) -> list[bool]: + return [check_nth_bit_from_left(self.value, index) for index in range(0, (len(self.value) * 8))] + + def is_reward_claimed_for_period(self, period_index) -> bool: + return check_nth_bit_from_left(self.value, period_index) + + +def get_reward_history_box_name(box_index) -> bytes: + return REWARD_HISTORY_BOX_PREFIX + int_to_bytes(box_index) + + +def get_reward_period_box_name(box_index) -> bytes: + return REWARD_PERIOD_BOX_PREFIX + int_to_bytes(box_index) + + +def get_account_reward_claim_sheet_box_name(address: str, box_index: int) -> bytes: + account_reward_claim_sheet_box_name = REWARD_CLAIM_SHEET_BOX_PREFIX + decode_address(address) + int_to_bytes(box_index) + return account_reward_claim_sheet_box_name + + +def parse_box_reward_history(raw_box) -> list[RewardHistory]: + box_size = REWARD_HISTORY_SIZE + rows = [raw_box[i:i + box_size] for i in range(0, len(raw_box), box_size)] + reward_histories = [] + for row in rows: + if row == (b'\x00' * box_size): + break + + reward_histories.append( + RewardHistory( + timestamp=bytes_to_int(row[:8]), + reward_amount=bytes_to_int(row[8:16]), + ) + ) + return reward_histories + + +def get_reward_histories(algod, app_id: int, reward_history_count: int) -> list[RewardHistory]: + box_count = math.ceil(reward_history_count / REWARD_HISTORY_BOX_ARRAY_LEN) + + reward_histories = [] + for box_index in range(box_count): + box_name = get_reward_history_box_name(box_index=box_index) + raw_box = get_raw_box_value(algod, app_id, box_name) + reward_histories.extend(parse_box_reward_history(raw_box)) + return reward_histories + + +def get_reward_history_index_at(reward_histories: list[RewardHistory], timestamp: int) -> Optional[int]: + reward_history_index = None + + for index, reward_history in enumerate(reward_histories): + if timestamp >= reward_history.timestamp: + reward_history_index = index + else: + break + + return reward_history_index + + +def parse_box_reward_period(raw_box) -> list[RewardPeriod]: + box_size = REWARD_PERIOD_SIZE + rows = [raw_box[i:i + box_size] for i in range(0, len(raw_box), box_size)] + reward_periods = [] + for row in rows: + if row == (b'\x00' * box_size): + break + + reward_periods.append( + RewardPeriod( + total_reward_amount=bytes_to_int(row[:8]), + total_cumulative_power_delta=bytes_to_int(row[8:24]), + ) + ) + return reward_periods + + +def get_reward_periods(algod, app_id: int, reward_period_count: int) -> list[RewardPeriod]: + box_count = math.ceil(reward_period_count / REWARD_PERIOD_BOX_ARRAY_LEN) + + reward_periods = [] + for box_index in range(box_count): + box_name = get_reward_period_box_name(box_index=box_index) + raw_box = get_raw_box_value(algod, app_id, box_name) + reward_periods.extend(parse_box_reward_period(raw_box)) + return reward_periods + + +def get_reward_claim_sheet(algod, app_id: int, address: str, account_reward_claim_sheet_box_index: int) -> Optional[RewardClaimSheet]: + box_name = get_account_reward_claim_sheet_box_name(address=address, box_index=account_reward_claim_sheet_box_index) + raw_box = get_raw_box_value(algod, app_id, box_name) + if raw_box is None: + return None + return RewardClaimSheet(value=raw_box) diff --git a/tinyman/governance/rewards/transactions.py b/tinyman/governance/rewards/transactions.py new file mode 100644 index 0000000..ca85b50 --- /dev/null +++ b/tinyman/governance/rewards/transactions.py @@ -0,0 +1,288 @@ +from typing import Optional + +from algosdk import transaction +from algosdk.logic import get_application_address + +from tinyman.compat import SuggestedParams +from tinyman.constants import MAX_APP_PROGRAM_COST +from tinyman.governance.rewards.constants import REWARDS_APP_MINIMUM_BALANCE_REQUIREMENT, REWARD_HISTORY_BOX_ARRAY_LEN, REWARD_CLAIM_SHEET_BOX_SIZE, REWARD_CLAIM_SHEET_BOX_COST, REWARD_PERIOD_BOX_COST, REWARD_PERIOD_BOX_ARRAY_LEN, REWARD_HISTORY_BOX_COST, INIT_APP_ARGUMENT, SET_REWARD_AMOUNT_APP_ARGUMENT, CREATE_REWARD_PERIOD_APP_ARGUMENT, CLAIM_REWARDS_APP_ARGUMENT, SET_REWARDS_MANAGER_APP_ARGUMENT +from tinyman.governance.rewards.storage import RewardsAppGlobalState, get_reward_history_box_name, get_account_reward_claim_sheet_box_name, get_reward_period_box_name +from tinyman.governance.transactions import _prepare_budget_increase_transaction, _prepare_get_box_transaction, _prepare_set_manager_transactions +from tinyman.governance.vault.constants import ACCOUNT_POWER_BOX_ARRAY_LEN, TOTAL_POWER_BOX_ARRAY_LEN +from tinyman.governance.vault.storage import get_total_power_box_name, get_account_state_box_name, get_account_power_box_name +from tinyman.utils import TransactionGroup, int_to_bytes +from algosdk.encoding import decode_address + + +def prepare_init_transactions( + rewards_app_id: int, + tiny_asset_id: int, + reward_amount: int, + sender: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + # Boxes + reward_histories_box_name = get_reward_history_box_name(box_index=0) + boxes = [ + (rewards_app_id, reward_histories_box_name), + ] + + payment_txn = transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(rewards_app_id), + amt=REWARDS_APP_MINIMUM_BALANCE_REQUIREMENT, + ) + application_call_txn = transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=rewards_app_id, + app_args=[ + INIT_APP_ARGUMENT, + reward_amount + ], + foreign_assets=[ + tiny_asset_id + ], + boxes=boxes, + note=app_call_note + ) + application_call_txn.fee *= 2 + + txns = [payment_txn, application_call_txn] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_set_reward_amount_transactions( + rewards_app_id: int, + rewards_app_global_state: RewardsAppGlobalState, + reward_amount: int, + sender: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + # Boxes + reward_history_index = rewards_app_global_state.reward_history_count + reward_history_box_index = reward_history_index // REWARD_HISTORY_BOX_ARRAY_LEN + reward_histories_box_name = get_reward_history_box_name(box_index=reward_history_box_index) + boxes = [ + (rewards_app_id, reward_histories_box_name), + ] + + application_call_txn = transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=rewards_app_id, + app_args=[ + SET_REWARD_AMOUNT_APP_ARGUMENT, + reward_amount + ], + boxes=boxes, + note=app_call_note + ) + txns = [application_call_txn] + + if not rewards_app_global_state.free_reward_history_space_count: + minimum_balance_payment = transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(rewards_app_id), + amt=REWARD_HISTORY_BOX_COST, + ) + txns.insert(0, minimum_balance_payment) + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_create_reward_period_transactions( + rewards_app_id: int, + vault_app_id: int, + sender: str, + rewards_app_global_state: RewardsAppGlobalState, + reward_history_index: int, + total_power_period_start_index: int, + total_power_period_end_index: int, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, + +) -> TransactionGroup: + total_power_period_start_box_index = total_power_period_start_index // TOTAL_POWER_BOX_ARRAY_LEN + total_power_period_end_box_index = total_power_period_end_index // TOTAL_POWER_BOX_ARRAY_LEN + reward_history_box_index = reward_history_index // REWARD_HISTORY_BOX_ARRAY_LEN + + reward_period_index = rewards_app_global_state.reward_period_count + reward_period_box_index = reward_period_index // REWARD_PERIOD_BOX_ARRAY_LEN + reward_period_array_index = reward_period_index % REWARD_PERIOD_BOX_ARRAY_LEN + + total_power_period_start_box_name = get_total_power_box_name(box_index=total_power_period_start_box_index) + total_power_period_end_box_name = get_total_power_box_name(box_index=total_power_period_end_box_index) + total_power_next_box_name = get_total_power_box_name(box_index=total_power_period_end_box_index + 1) + reward_history_box_name = get_reward_history_box_name(box_index=reward_history_box_index) + reward_period_box_name = get_reward_period_box_name(box_index=reward_period_box_index) + + boxes = [ + (rewards_app_id, reward_history_box_name), + (rewards_app_id, reward_period_box_name), + (vault_app_id, total_power_period_start_box_name), + (vault_app_id, total_power_period_end_box_name), + (vault_app_id, total_power_next_box_name), + ] + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=rewards_app_id, + app_args=[ + CREATE_REWARD_PERIOD_APP_ARGUMENT, + total_power_period_start_index, + total_power_period_end_index, + reward_history_index + ], + foreign_apps=[vault_app_id], + boxes=boxes, + note=app_call_note, + ), + ] + txns[0].fee *= 2 + + if reward_period_array_index == 0: + minimum_balance_payment = transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(rewards_app_id), + amt=REWARD_PERIOD_BOX_COST, + ) + txns.insert(0, minimum_balance_payment) + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_claim_reward_transactions( + rewards_app_id: int, + vault_app_id: int, + tiny_asset_id: int, + sender: str, + period_index_start: int, + period_count: int, + account_power_indexes: list[int], + create_reward_claim_sheet: bool, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + account_state_box_name = get_account_state_box_name(address=sender) + assert period_count <= 104 + + reward_period_boxes = set() + account_reward_claim_sheet_boxes = set() + for period_index in range(period_index_start, period_index_start + period_count): + reward_period_box_index = period_index // REWARD_PERIOD_BOX_ARRAY_LEN + box_name = get_reward_period_box_name(box_index=reward_period_box_index) + reward_period_boxes.add((rewards_app_id, box_name)) + + account_reward_claim_sheet_box_index = period_index // (REWARD_CLAIM_SHEET_BOX_SIZE * 8) + box_name = get_account_reward_claim_sheet_box_name(address=sender, box_index=account_reward_claim_sheet_box_index) + account_reward_claim_sheet_boxes.add((rewards_app_id, box_name)) + + account_power_boxes = set() + for account_power_index in account_power_indexes: + account_power_box_index = account_power_index // ACCOUNT_POWER_BOX_ARRAY_LEN + box_name = get_account_power_box_name(address=sender, box_index=account_power_box_index) + account_power_boxes.add((vault_app_id, box_name)) + box_name = get_account_power_box_name(address=sender, box_index=account_power_box_index + 1) + account_power_boxes.add((vault_app_id, box_name)) + + boxes = [ + (vault_app_id, account_state_box_name), + *account_reward_claim_sheet_boxes, # MAX 2 + *reward_period_boxes, # MAX 2 + *account_power_boxes, # MAX 5 + ] + assert len(boxes) < 11 + + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=rewards_app_id, + app_args=[ + CLAIM_REWARDS_APP_ARGUMENT, + period_index_start, + period_count, + b''.join([int_to_bytes(i) for i in account_power_indexes]) + ], + foreign_apps=[vault_app_id], + foreign_assets=[tiny_asset_id], + boxes=boxes[:6], # Max total txn reference is 8 (MaxAppTotalTxnReferences), remaining is 2 for boxes. + note=app_call_note, + ), + ] + txns[0].fee *= (period_count + 2) + + increase_budget_txn_count = 0 + required_opcode_budget = 92 + 865 * period_count + + opcode_budget = MAX_APP_PROGRAM_COST + MAX_APP_PROGRAM_COST * period_count + if required_opcode_budget > opcode_budget: + increase_budget_txn_count = ((required_opcode_budget - opcode_budget) // 666) + 1 + + if increase_budget_txn_count or boxes[6:]: + budget_increase_txn = _prepare_budget_increase_transaction( + sender, + extra_app_args=[max(increase_budget_txn_count - 1, 0)], + sp=suggested_params, + index=rewards_app_id, + foreign_apps=[vault_app_id], + boxes=boxes[6:] + ) + budget_increase_txn.fee *= max(increase_budget_txn_count, 1) + txns.insert(0, budget_increase_txn) + + if create_reward_claim_sheet: + minimum_balance_payment = transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(rewards_app_id), + amt=REWARD_CLAIM_SHEET_BOX_COST, + ) + txns.insert(0, minimum_balance_payment) + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_set_manager_transactions( + rewards_app_id: int, + **kwargs +) -> TransactionGroup: + return _prepare_set_manager_transactions(app_id=rewards_app_id, **kwargs) + + +def prepare_set_rewards_manager_transactions( + rewards_app_id: int, + sender: str, + new_manager_address: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=rewards_app_id, + app_args=[SET_REWARDS_MANAGER_APP_ARGUMENT, decode_address(new_manager_address)], + note=app_call_note, + ), + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_get_box_transaction( + rewards_app_id: int, + **kwargs +) -> TransactionGroup: + return _prepare_get_box_transaction(app_id=rewards_app_id, **kwargs) diff --git a/tinyman/governance/rewards/utils.py b/tinyman/governance/rewards/utils.py new file mode 100644 index 0000000..5bfd179 --- /dev/null +++ b/tinyman/governance/rewards/utils.py @@ -0,0 +1,26 @@ +from tinyman.governance.rewards.storage import RewardPeriod + + +def calculate_reward_amount(account_cumulative_power_delta: int, reward_period: RewardPeriod): + return reward_period.total_reward_amount * account_cumulative_power_delta // reward_period.total_cumulative_power_delta + + +def group_adjacent_period_indexes(indexes: list[int]) -> list[list[int]]: + if not indexes: # Handle empty list + return [] + + grouped = [] + current_group = [indexes[0]] + + for i in range(1, len(indexes)): + # Check if the current number is adjacent to the previous number + if indexes[i] - indexes[i - 1] == 1: + current_group.append(indexes[i]) + else: + grouped.append(current_group) + current_group = [indexes[i]] + + # Append the last group after exiting the loop + grouped.append(current_group) + + return grouped diff --git a/tinyman/governance/staking_voting/__init__.py b/tinyman/governance/staking_voting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tinyman/governance/staking_voting/constants.py b/tinyman/governance/staking_voting/constants.py new file mode 100644 index 0000000..1b27bae --- /dev/null +++ b/tinyman/governance/staking_voting/constants.py @@ -0,0 +1,31 @@ +from tinyman.constants import MINIMUM_BALANCE_REQUIREMENT_PER_BOX, MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE + +MAX_OPTION_COUNT = 16 + +# Global States +PROPOSAL_INDEX_COUNTER_KEY = b'proposal_index_counter' +VOTING_DELAY_KEY = b'voting_delay' +VOTING_DURATION_KEY = b'voting_duration' +MANAGER_KEY = b'manager' +PROPOSAL_MANAGER_KEY = b'proposal_manager' + +# Box +PROPOSAL_BOX_PREFIX = b'p' +STAKING_VOTE_BOX_PREFIX = b'v' +STAKING_ATTENDANCE_BOX_PREFIX = b'a' + +STAKING_PROPOSAL_BOX_SIZE = 49 +STAKING_VOTE_BOX_SIZE = 8 +STAKING_ACCOUNT_ATTENDANCE_SHEET_BOX_SIZE = 24 + +STAKING_VOTING_APP_MINIMUM_BALANCE_REQUIREMENT = 100_000 + +STAKING_PROPOSAL_BOX_COST = MINIMUM_BALANCE_REQUIREMENT_PER_BOX + MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE * (60 + STAKING_PROPOSAL_BOX_SIZE) +STAKING_VOTE_BOX_COST = MINIMUM_BALANCE_REQUIREMENT_PER_BOX + MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE * (17 + STAKING_VOTE_BOX_SIZE) +STAKING_ATTENDANCE_BOX_COST = MINIMUM_BALANCE_REQUIREMENT_PER_BOX + MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE * (41 + STAKING_ACCOUNT_ATTENDANCE_SHEET_BOX_SIZE) + +STAKING_PROPOSAL_CATEGORY = "farming-rewards-distribution" + +CREATE_PROPOSAL_APP_ARGUMENT = b"create_proposal" +CANCEL_PROPOSAL_APP_ARGUMENT = b"cancel_proposal" +CAST_VOTE_APP_ARGUMENT = b"cast_vote" diff --git a/tinyman/governance/staking_voting/events.py b/tinyman/governance/staking_voting/events.py new file mode 100644 index 0000000..66359ef --- /dev/null +++ b/tinyman/governance/staking_voting/events.py @@ -0,0 +1,94 @@ +from algosdk import abi + +from tinyman.governance.event import Event + + +event_proposal = Event( + name="proposal", + args=[ + abi.Argument(arg_type="byte[59]", name="proposal_id"), + abi.Argument(arg_type="uint64", name="index"), + abi.Argument(arg_type="uint64", name="creation_timestamp"), + abi.Argument(arg_type="uint64", name="voting_start_timestamp"), + abi.Argument(arg_type="uint64", name="voting_end_timestamp"), + abi.Argument(arg_type="uint64", name="voting_power"), + abi.Argument(arg_type="uint64", name="vote_count"), + abi.Argument(arg_type="bool", name="is_cancelled"), + ] +) + +event_create_proposal = Event( + name="create_proposal", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="byte[59]", name="proposal_id"), + ] +) + +event_cancel_proposal = Event( + name="cancel_proposal", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="byte[59]", name="proposal_id"), + ] +) + +event_vote = Event( + name="vote", + args=[ + abi.Argument(arg_type="uint64", name="asset_id"), + abi.Argument(arg_type="uint64", name="voting_power"), + abi.Argument(arg_type="uint64", name="vote_percentage"), + ] +) + +event_cast_vote = Event( + name="cast_vote", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="byte[59]", name="proposal_id"), + abi.Argument(arg_type="uint64", name="voting_power"), + ] +) + +event_set_manager = Event( + name="set_manager", + args=[ + abi.Argument(arg_type="address", name="manager"), + ] +) + +event_set_proposal_manager = Event( + name="set_proposal_manager", + args=[ + abi.Argument(arg_type="address", name="proposal_manager"), + ] +) + +event_set_voting_delay = Event( + name="set_voting_delay", + args=[ + abi.Argument(arg_type="uint64", name="voting_delay"), + ] +) + +event_set_voting_duration = Event( + name="set_voting_duration", + args=[ + abi.Argument(arg_type="uint64", name="voting_duration"), + ] +) + +staking_voting_events = [ + # method calls + event_create_proposal, + event_cancel_proposal, + event_cast_vote, + event_set_manager, + event_set_proposal_manager, + event_set_voting_delay, + event_set_voting_duration, + # boxes + event_vote, + event_proposal, +] diff --git a/tinyman/governance/staking_voting/storage.py b/tinyman/governance/staking_voting/storage.py new file mode 100644 index 0000000..bc3b9c9 --- /dev/null +++ b/tinyman/governance/staking_voting/storage.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +from typing import Optional + +from algosdk.encoding import decode_address + +from tinyman.governance.proposal_voting.storage import get_proposal_box_name +from tinyman.governance.staking_voting.constants import PROPOSAL_BOX_PREFIX, STAKING_VOTE_BOX_PREFIX, STAKING_ATTENDANCE_BOX_PREFIX +from tinyman.governance.utils import check_nth_bit_from_left, get_raw_box_value +from tinyman.utils import int_to_bytes, bytes_to_int + + +@dataclass +class StakingVotingAppGlobalState: + vault_app_id: int + proposal_index_counter: int + voting_delay: int + voting_duration: int + manager: str + proposal_manager: str + + +@dataclass +class StakingDistributionProposal: + index: int + creation_timestamp: int + voting_start_timestamp: int + voting_end_timestamp: int + voting_power: int + vote_count: int + is_cancelled: bool + + @property + def snapshot_timestamp(self) -> int: + return self.creation_timestamp + + +@dataclass +class StakingVotingAttendanceSheet: + value: bytes + + @property + def attendance_sheet(self) -> list[bool]: + return [check_nth_bit_from_left(self.value, index) for index in range(0, (len(self.value) * 8))] + + def is_vote_casted_for_proposal(self, proposal_index) -> bool: + return check_nth_bit_from_left(self.value, proposal_index) + + +def get_staking_distribution_proposal_box_name(proposal_id: str) -> bytes: + return PROPOSAL_BOX_PREFIX + proposal_id.encode() + + +def get_staking_vote_box_name(proposal_index: int, asset_id: int) -> bytes: + return STAKING_VOTE_BOX_PREFIX + int_to_bytes(proposal_index) + int_to_bytes(asset_id) + + +def get_staking_attendance_sheet_box_name(address: str, box_index: int) -> bytes: + attendance_sheet_box_name = STAKING_ATTENDANCE_BOX_PREFIX + decode_address(address) + int_to_bytes(box_index) + return attendance_sheet_box_name + + +def parse_box_staking_distribution_proposal(raw_box) -> StakingDistributionProposal: + return StakingDistributionProposal( + index=bytes_to_int(raw_box[:8]), + creation_timestamp=bytes_to_int(raw_box[8:16]), + voting_start_timestamp=bytes_to_int(raw_box[16:24]), + voting_end_timestamp=bytes_to_int(raw_box[24:32]), + voting_power=bytes_to_int(raw_box[32:40]), + vote_count=bytes_to_int(raw_box[40:48]), + is_cancelled=bool(bytes_to_int(raw_box[48:49])), + ) + + +def get_staking_distribution_proposal(algod, app_id: int, proposal_id: str) -> Optional[StakingDistributionProposal]: + box_name = get_proposal_box_name(proposal_id) + raw_box = get_raw_box_value(algod, app_id, box_name) + if not raw_box: + return None + return parse_box_staking_distribution_proposal(raw_box) diff --git a/tinyman/governance/staking_voting/transactions.py b/tinyman/governance/staking_voting/transactions.py new file mode 100644 index 0000000..d1a18f6 --- /dev/null +++ b/tinyman/governance/staking_voting/transactions.py @@ -0,0 +1,222 @@ +from typing import Optional + +from algosdk import transaction +from algosdk.logic import get_application_address + +from tinyman.compat import SuggestedParams +from tinyman.governance.staking_voting.constants import STAKING_PROPOSAL_BOX_COST, STAKING_VOTE_BOX_COST, STAKING_ATTENDANCE_BOX_COST, MAX_OPTION_COUNT, \ + STAKING_PROPOSAL_CATEGORY, CREATE_PROPOSAL_APP_ARGUMENT, CANCEL_PROPOSAL_APP_ARGUMENT, CAST_VOTE_APP_ARGUMENT +from tinyman.governance.staking_voting.storage import StakingDistributionProposal, get_staking_attendance_sheet_box_name, get_staking_distribution_proposal_box_name, \ + get_staking_vote_box_name +from tinyman.governance.transactions import _prepare_budget_increase_transaction, _prepare_get_box_transaction, _prepare_set_manager_transactions, \ + _prepare_set_proposal_manager_transactions, _prepare_set_voting_delay_transactions, _prepare_set_voting_duration_transactions +from tinyman.governance.vault.constants import ACCOUNT_POWER_BOX_ARRAY_LEN +from tinyman.governance.vault.storage import get_account_state_box_name, get_account_power_box_name +from tinyman.utils import int_to_bytes, TransactionGroup + + +def generate_staking_distribution_proposal_metadata( + title: str, + description: str, + staking_program_start_time: int, + staking_program_end_time: int, + staking_program_cycle_duration: int, + staking_program_reward_asset: int, +): + # keys are sorted. + metadata = dict( + category=STAKING_PROPOSAL_CATEGORY, + description=description, + staking_program=dict( + cycle_duration=staking_program_cycle_duration, + end_date=staking_program_end_time, + reward_asset=staking_program_reward_asset, + start_date=staking_program_start_time, + ), + title=title, + ) + for key, value in metadata.items(): + if isinstance(value, str): + metadata[key] = value.strip() + return metadata + + +def prepare_create_staking_distribution_proposal_transactions( + staking_voting_app_id: int, + sender: str, + proposal_id: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +): + # Only proposal manager can call this method. + proposal_box_name = get_staking_distribution_proposal_box_name(proposal_id) + + txns = [ + transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(staking_voting_app_id), + amt=STAKING_PROPOSAL_BOX_COST, + ), + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=staking_voting_app_id, + app_args=[CREATE_PROPOSAL_APP_ARGUMENT, proposal_id], + boxes=[ + (staking_voting_app_id, proposal_box_name), + ], + note=app_call_note + ) + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_cancel_staking_distribution_proposal_transactions( + staking_voting_app_id: int, + sender: str, + proposal_id: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +): + # Only proposal manager can call this method. + proposal_box_name = get_staking_distribution_proposal_box_name(proposal_id) + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=staking_voting_app_id, + app_args=[CANCEL_PROPOSAL_APP_ARGUMENT, proposal_id], + boxes=[ + (staking_voting_app_id, proposal_box_name), + ], + note=app_call_note + ) + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_cast_vote_for_staking_distribution_proposal_transactions( + staking_voting_app_id: int, + vault_app_id: int, + sender: str, + proposal_id: str, + proposal: StakingDistributionProposal, + votes: list[int], + asset_ids: list[int], + account_power_index: int, + app_box_names: list[bytes], + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None +): + # All governors who have voting power at the creation time of can vote + assert (len(votes) == len(asset_ids)) + assert len(asset_ids) <= MAX_OPTION_COUNT + assert sum(votes) == 100 + + arg_votes = b"".join([int_to_bytes(vote) for vote in votes]) + arg_asset_ids = b"".join([int_to_bytes(asset_id) for asset_id in asset_ids]) + + proposal_box_name = get_staking_distribution_proposal_box_name(proposal_id) + + account_attendance_sheet_box_index = proposal.index // (1024 * 8) + account_attendance_sheet_box_name = get_staking_attendance_sheet_box_name(address=sender, box_index=account_attendance_sheet_box_index) + create_attendance_sheet_box = account_attendance_sheet_box_name not in app_box_names + + account_state_box_name = get_account_state_box_name(address=sender) + account_power_box_index = account_power_index // ACCOUNT_POWER_BOX_ARRAY_LEN + account_power_box_name = get_account_power_box_name(address=sender, box_index=account_power_box_index) + next_account_power_box_name = get_account_power_box_name(address=sender, box_index=account_power_box_index + 1) + + new_asset_count = 0 + vote_boxes = [] + for asset_id in asset_ids: + vote_box_name = get_staking_vote_box_name(proposal.index, asset_id) + vote_boxes.append((staking_voting_app_id, vote_box_name)) + if vote_box_name not in app_box_names: + new_asset_count += 1 + + boxes = [ + (staking_voting_app_id, proposal_box_name), + (staking_voting_app_id, account_attendance_sheet_box_name), + *vote_boxes, + (vault_app_id, account_state_box_name), + (vault_app_id, account_power_box_name), + (vault_app_id, next_account_power_box_name), + ] + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=staking_voting_app_id, + app_args=[CAST_VOTE_APP_ARGUMENT, proposal_id, arg_votes, arg_asset_ids, account_power_index], + foreign_apps=[vault_app_id], + boxes=boxes[:7], + note=app_call_note + ), + ] + txns[0].fee *= 2 + + if len(boxes) >= 7: + txns.append( + _prepare_budget_increase_transaction(sender, sp=suggested_params, index=vault_app_id, foreign_apps=[staking_voting_app_id], boxes=boxes[7:14]), + ) + if len(boxes) >= 14: + txns.append( + _prepare_budget_increase_transaction(sender, sp=suggested_params, index=vault_app_id, foreign_apps=[staking_voting_app_id], boxes=boxes[14:]), + ) + + payment_amount = (create_attendance_sheet_box * STAKING_ATTENDANCE_BOX_COST) + (new_asset_count * STAKING_VOTE_BOX_COST) + if payment_amount: + txns = [ + transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(staking_voting_app_id), + amt=payment_amount, + ) + ] + txns + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_set_manager_transactions( + staking_voting_app_id: int, + **kwargs +): + # Only proposal manager can call this method. + return _prepare_set_manager_transactions(app_id=staking_voting_app_id, **kwargs) + + +def prepare_set_proposal_manager_transactions( + staking_voting_app_id: int, + **kwargs +): + # Only manager can call this method. + return _prepare_set_proposal_manager_transactions(app_id=staking_voting_app_id, **kwargs) + + +def prepare_set_voting_delay_transactions( + staking_voting_app_id: int, + **kwargs +): + # Only manager can call this method. + return _prepare_set_voting_delay_transactions(app_id=staking_voting_app_id, **kwargs) + + +def prepare_set_voting_duration_transactions( + staking_voting_app_id: int, + **kwargs +): + # Only manager can call this method. + return _prepare_set_voting_duration_transactions(app_id=staking_voting_app_id, **kwargs) + + +def prepare_get_box_transaction( + staking_voting_app_id: int, + **kwargs +) -> TransactionGroup: + return _prepare_get_box_transaction(app_id=staking_voting_app_id, **kwargs) diff --git a/tinyman/governance/transactions.py b/tinyman/governance/transactions.py new file mode 100644 index 0000000..4acb253 --- /dev/null +++ b/tinyman/governance/transactions.py @@ -0,0 +1,154 @@ +import uuid +from typing import Optional, Union + +from algosdk import transaction +from algosdk.encoding import decode_address + +from tinyman.compat import SuggestedParams +from tinyman.constants import MAX_APP_TOTAL_TXN_REFERENCES +from tinyman.governance.constants import INCREASE_BUDGET_APP_ARGUMENT, GET_BOX_APP_ARGUMENT, SET_MANAGER_APP_ARGUMENT, SET_PROPOSAL_MANAGER_APP_ARGUMENT, SET_VOTING_DELAY_APP_ARGUMENT, SET_VOTING_DURATION_APP_ARGUMENT +from tinyman.utils import TransactionGroup + + +def _prepare_budget_increase_transaction( + sender: str, + sp: transaction.SuggestedParams, + index: int, + extra_app_args: Optional[list[Union[bytes, bytearray, str, int]]] = None, + foreign_apps: list[int] = None, + boxes: list[(int, bytes)] = None +): + """ + It increases opcode budget and box read budget. + """ + if foreign_apps is None: + foreign_apps = [] + + if boxes is None: + boxes = [] + + # MaxAppTotalTxnReferences (Max number of foreign accounts + ASAs + applications + box storage) + boxes = boxes + ([(0, "")] * ((MAX_APP_TOTAL_TXN_REFERENCES - len(foreign_apps)) - len(boxes))) + + return transaction.ApplicationNoOpTxn( + sender=sender, + sp=sp, + index=index, + app_args=[INCREASE_BUDGET_APP_ARGUMENT] + (extra_app_args if extra_app_args else []), + foreign_apps=foreign_apps, + boxes=boxes, + # Make transactions unique to avoid "transaction already in ledger" error + note=uuid.uuid4().bytes + ) + + +def _prepare_get_box_transaction( + app_id: int, + sender: str, + box_name: Union[bytes, bytearray, str, int], + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=app_id, + app_args=[ + GET_BOX_APP_ARGUMENT, + box_name + ], + boxes=[ + (app_id, box_name), + ], + note=app_call_note, + ), + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def _prepare_set_manager_transactions( + app_id: int, + sender: str, + new_manager_address: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None +): + # Only proposal manager can call this method. + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=app_id, + app_args=[SET_MANAGER_APP_ARGUMENT, decode_address(new_manager_address)], + note=app_call_note + ), + ] + + txn_group = TransactionGroup(txns) + return txn_group + + +def _prepare_set_proposal_manager_transactions( + app_id: int, + sender: str, + new_manager_address: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None +): + # Only manager can call this method. + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=app_id, + app_args=[SET_PROPOSAL_MANAGER_APP_ARGUMENT, decode_address(new_manager_address)], + note=app_call_note + ), + ] + + txn_group = TransactionGroup(txns) + return txn_group + + +def _prepare_set_voting_delay_transactions( + app_id: int, + sender: str, + new_voting_delay: int, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None +): + # Only manager can call this method. + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=app_id, + app_args=[SET_VOTING_DELAY_APP_ARGUMENT, new_voting_delay], + note=app_call_note + ), + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def _prepare_set_voting_duration_transactions( + app_id: int, + sender: str, + new_voting_duration: int, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None +): + # Only manager can call this method. + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=app_id, + app_args=[SET_VOTING_DURATION_APP_ARGUMENT, new_voting_duration], + note=app_call_note + ), + ] + txn_group = TransactionGroup(txns) + return txn_group diff --git a/tinyman/governance/utils.py b/tinyman/governance/utils.py new file mode 100644 index 0000000..664fc37 --- /dev/null +++ b/tinyman/governance/utils.py @@ -0,0 +1,118 @@ +import json +import pickle +from base64 import b64decode +from hashlib import sha256 +from typing import Optional + +from algosdk.error import AlgodHTTPError +from multiformats import CID + +from tinyman.constants import MINIMUM_BALANCE_REQUIREMENT_PER_BOX, MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE + + +def get_raw_box_value( + algod, + app_id: int, + box_name: bytes, + cache: bool = False +) -> Optional[bytes]: + cache_filename = f"tinyman-governance-box-cache-{app_id}" + + cache_data = {} + if cache: + try: + with open(cache_filename, 'rb') as cache_file: + cache_data = pickle.load(cache_file) + except FileNotFoundError: + pass + + if box_name in cache_data: + raw_box = cache_data[box_name] + else: + try: + response = algod.application_box_by_name(app_id, box_name) + except AlgodHTTPError as e: + if str(e) != 'box not found': + raise e + return None + + value = response["value"] + raw_box = b64decode(value) + + if cache: + cache_data[box_name] = raw_box + with open(cache_filename, 'wb') as cache_file: + pickle.dump(cache_data, cache_file) + return raw_box + + +def get_all_box_names(algod, app_id: int) -> list[bytes]: + response = algod.application_boxes(app_id, limit=0) + box_names = [b64decode(box["name"]) for box in response["boxes"]] + return box_names + + +def box_exists(algod, app_id: int, box_name: bytes) -> bool: + return get_raw_box_value(algod, app_id, box_name) is not None + + +def parse_global_state_from_application_info(application_info: dict) -> dict: + raw_global_state = application_info["params"]["global-state"] + + global_state = {} + for pair in raw_global_state: + key = b64decode(pair["key"]).decode() + if pair["value"]["type"] == 1: + value = b64decode(pair["value"].get("bytes", "")) + else: + value = pair["value"].get("uint", 0) + global_state[key] = value + + return global_state + + +def get_global_state(algod, app_id: int) -> dict: + application_info = algod.application_info(app_id) + global_state = parse_global_state_from_application_info(application_info) + return global_state + + +def check_nth_bit_from_left(value_bytes: bytes, n: int) -> int: + # ensure n is within the range of the bytes + if n >= len(value_bytes) * 8: + raise ValueError(f"n should be less than {len(value_bytes) * 8}") + + # convert bytes to int + num = int.from_bytes(value_bytes, 'big') + + # calculate which bit to check from the left + bit_to_check = (len(value_bytes) * 8 - 1) - n + + # create a number with nth bit set + nth_bit = 1 << bit_to_check + + # if the nth bit is set in the given number, return 1. Otherwise, return 0 + if num & nth_bit: + return 1 + else: + return 0 + + +def get_required_minimum_balance_of_box(box_name: bytes, box_size: int): + return MINIMUM_BALANCE_REQUIREMENT_PER_BOX + MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE * (len(box_name) + box_size) + + +def serialize_metadata(metadata: dict) -> str: + serialized_metadata = json.dumps(metadata, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return serialized_metadata + + +def generate_cid_from_serialized_metadata(serialized_metadata: str) -> str: + digest = sha256(serialized_metadata.encode('utf-8')).digest() + cid = CID("base32", 1, "raw", ("sha2-256", digest)) + return str(cid) + + +def generate_cid_from_proposal_metadata(metadata: dict) -> str: + serialized_metadata = serialize_metadata(metadata) + return generate_cid_from_serialized_metadata(serialized_metadata) diff --git a/tinyman/governance/vault/__init__.py b/tinyman/governance/vault/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tinyman/governance/vault/constants.py b/tinyman/governance/vault/constants.py new file mode 100644 index 0000000..4cc8ca1 --- /dev/null +++ b/tinyman/governance/vault/constants.py @@ -0,0 +1,61 @@ +from tinyman.constants import MINIMUM_BALANCE_REQUIREMENT_PER_BOX, MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE +from tinyman.governance.constants import DAY, WEEK + +MIN_LOCK_TIME = 4 * WEEK # 4 WEEK +MAX_LOCK_TIME = 4 * 52 * WEEK # 364 * 4 Days +MIN_LOCK_AMOUNT = 10_000_000 # 10 TINY +MIN_LOCK_AMOUNT_INCREMENT = 10_000_000 # 10 TINY +TWO_TO_THE_64 = 2 ** 64 # Scaling factor +# TWO_TO_THE_64 = "\x01\x00\x00\x00\x00\x00\x00\x00\x00" + +# Global states +TOTAL_LOCKED_AMOUNT_KEY = b'total_locked_amount' +TOTAL_POWER_COUNT_KEY = b'total_power_count' +LAST_TOTAL_POWER_TIMESTAMP_KEY = b'last_total_power_timestamp' + +# Boxes +TOTAL_POWERS = b'tp' +SLOPE_CHANGES = b'sc' + +ACCOUNT_STATE_BOX_SIZE = 32 +SLOPE_CHANGE_BOX_SIZE = 16 + +ACCOUNT_POWER_SIZE = 48 +ACCOUNT_POWER_BOX_SIZE = 1008 +ACCOUNT_POWER_BOX_ARRAY_LEN = 21 + +TOTAL_POWER_SIZE = 48 +TOTAL_POWER_BOX_SIZE = 1008 +TOTAL_POWER_BOX_ARRAY_LEN = 21 + +# 100_000 Default +# 100_000 Opt-in +# Box +# https://developer.algorand.org/docs/get-details/dapps/smart-contracts/apps/?from_query=box#minimum-balance-requirement-for-boxes +# 2500 + 400 * (len(n)+s) +# 2_500 Box +# 407_200 = 400 * (10 + 1008) +VAULT_APP_MINIMUM_BALANCE_REQUIREMENT = 609_700 + + +ACCOUNT_STATE_BOX_COST = MINIMUM_BALANCE_REQUIREMENT_PER_BOX + MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE * (32 + ACCOUNT_STATE_BOX_SIZE) +SLOPE_CHANGE_BOX_COST = MINIMUM_BALANCE_REQUIREMENT_PER_BOX + MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE * (10 + SLOPE_CHANGE_BOX_SIZE) +ACCOUNT_POWER_BOX_COST = MINIMUM_BALANCE_REQUIREMENT_PER_BOX + MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE * (40 + ACCOUNT_POWER_BOX_SIZE) +TOTAL_POWER_BOX_COST = MINIMUM_BALANCE_REQUIREMENT_PER_BOX + MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE * (10 + TOTAL_POWER_BOX_SIZE) + +INIT_APP_ARGUMENT = b"init" +CREATE_LOCK_APP_ARGUMENT = b"create_lock" +CREATE_CHECKPOINTS_APP_ARGUMENT = b"create_checkpoints" +INCREASE_LOCK_AMOUNT_APP_ARGUMENT = b"increase_lock_amount" +EXTEND_LOCK_END_TIME_APP_ARGUMENT = b"extend_lock_end_time" +WITHDRAW_APP_ARGUMENT = b"withdraw" +GET_TINY_POWER_OF_APP_ARGUMENT = b"get_tiny_power_of" +GET_TINY_POWER_OF_AT_APP_ARGUMENT = b"get_tiny_power_of_at" +GET_TOTAL_TINY_POWER_APP_ARGUMENT = b"get_total_tiny_power" +GET_TOTAL_TINY_POWER_AT_APP_ARGUMENT = b"get_total_tiny_power_at" +GET_TOTAL_CUMULATIVE_POWER_AT_APP_ARGUMENT = b"get_total_cumulative_power_at" +GET_CUMULATIVE_POWER_OF_AT_APP_ARGUMENT = b"get_cumulative_power_of_at" +GET_ACCOUNT_CUMULATIVE_POWER_DELTA_APP_PREFIX = b"get_account_cumulative_power_delta" +GET_TOTAL_CUMULATIVE_POWER_DELTA_APP_PREFIX = b"get_total_cumulative_power_delta" +DELETE_ACCOUNT_POWER_BOXES_APP_ARGUMENT = b"delete_account_power_boxes" +DELETE_ACCOUNT_STATE_APP_ARGUMENT = b"delete_account_state" diff --git a/tinyman/governance/vault/events.py b/tinyman/governance/vault/events.py new file mode 100644 index 0000000..20bcabf --- /dev/null +++ b/tinyman/governance/vault/events.py @@ -0,0 +1,123 @@ +from algosdk import abi + +from tinyman.governance.event import Event + +event_init = Event( + name="init", + args=[] +) + +event_create_checkpoints = Event( + name="create_checkpoints", + args=[] +) + +event_del_box = Event( + name="box_del", + args=[ + abi.Argument(arg_type="byte[]", name="box_name"), + ] +) + +event_delete_account_state = Event( + name="delete_account_state", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="uint64", name="box_index_start"), + abi.Argument(arg_type="uint64", name="box_count"), + ] +) + +event_delete_account_power_boxes = Event( + name="delete_account_power_boxes", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="uint64", name="box_index_start"), + abi.Argument(arg_type="uint64", name="box_count"), + ] +) + +event_create_lock = Event( + name="create_lock", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="uint64", name="locked_amount"), + abi.Argument(arg_type="uint64", name="lock_end_time"), + ] +) + +event_increase_lock_amount = Event( + name="increase_lock_amount", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="uint64", name="locked_amount"), + abi.Argument(arg_type="uint64", name="lock_end_time"), + abi.Argument(arg_type="uint64", name="amount_delta"), + ] +) + +event_extend_lock_end_time = Event( + name="extend_lock_end_time", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="uint64", name="locked_amount"), + abi.Argument(arg_type="uint64", name="lock_end_time"), + abi.Argument(arg_type="uint64", name="time_delta"), + ] +) + +event_withdraw = Event( + name="withdraw", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="uint64", name="amount"), + ] +) + +event_account_power = Event( + name="account_power", + args=[ + abi.Argument(arg_type="address", name="user_address"), + abi.Argument(arg_type="uint64", name="index"), + abi.Argument(arg_type="uint64", name="bias"), + abi.Argument(arg_type="uint64", name="timestamp"), + abi.Argument(arg_type="uint128", name="slope"), + abi.Argument(arg_type="uint128", name="cumulative_power"), + ] +) + +event_total_power = Event( + name="total_power", + args=[ + abi.Argument(arg_type="uint64", name="index"), + abi.Argument(arg_type="uint64", name="bias"), + abi.Argument(arg_type="uint64", name="timestamp"), + abi.Argument(arg_type="uint128", name="slope"), + abi.Argument(arg_type="uint128", name="cumulative_power"), + ] +) + +event_slope_change = Event( + name="slope_change", + args=[ + abi.Argument(arg_type="uint64", name="timestamp"), + abi.Argument(arg_type="uint128", name="slope"), + ] +) + +vault_events = [ + # method calls + event_init, + event_create_checkpoints, + event_create_lock, + event_increase_lock_amount, + event_extend_lock_end_time, + event_withdraw, + event_del_box, + event_delete_account_state, + event_delete_account_power_boxes, + # boxes + event_account_power, + event_total_power, + event_slope_change, +] diff --git a/tinyman/governance/vault/exceptions.py b/tinyman/governance/vault/exceptions.py new file mode 100644 index 0000000..007c4a0 --- /dev/null +++ b/tinyman/governance/vault/exceptions.py @@ -0,0 +1,14 @@ +class InsufficientLockAmount(Exception): + pass + + +class InvalidLockEndTime(Exception): + pass + + +class ShortLockEndTime(Exception): + pass + + +class TooLongLockEndTime(Exception): + pass diff --git a/tinyman/governance/vault/storage.py b/tinyman/governance/vault/storage.py new file mode 100644 index 0000000..b87c8ef --- /dev/null +++ b/tinyman/governance/vault/storage.py @@ -0,0 +1,236 @@ +import math +from dataclasses import dataclass +from typing import Optional, Tuple, Union + +from algosdk.encoding import decode_address + +from tinyman.governance.utils import get_raw_box_value +from tinyman.governance.vault.constants import TOTAL_POWERS, SLOPE_CHANGES, ACCOUNT_POWER_BOX_ARRAY_LEN, TOTAL_POWER_BOX_ARRAY_LEN, ACCOUNT_POWER_SIZE, TOTAL_POWER_SIZE, TWO_TO_THE_64 +from tinyman.utils import int_to_bytes, bytes_to_int + + +@dataclass +class VaultAppGlobalState: + tiny_asset_id: int + total_locked_amount: int + total_power_count: int + last_total_power_timestamp: int + + @property + def free_total_power_space_count(self) -> int: + remainder = self.total_power_count % TOTAL_POWER_BOX_ARRAY_LEN + return TOTAL_POWER_BOX_ARRAY_LEN - remainder if remainder > 0 else 0 + + @property + def last_total_power_box_index(self) -> int: + return get_last_total_powers_indexes(self.total_power_count)[0] + + @property + def last_total_power_array_index(self) -> int: + return get_last_total_powers_indexes(self.total_power_count)[1] + + +@dataclass +class AccountState: + locked_amount: int + lock_end_time: int + power_count: int + deleted_power_count: int + + @property + def free_account_power_space_count(self) -> int: + remainder = self.power_count % ACCOUNT_POWER_BOX_ARRAY_LEN + return ACCOUNT_POWER_BOX_ARRAY_LEN - remainder if remainder > 0 else 0 + + @property + def last_account_power_box_index(self) -> int: + return get_last_account_power_box_indexes(self.power_count)[0] + + @property + def last_account_power_array_index(self) -> int: + return get_last_account_power_box_indexes(self.power_count)[1] + + +@dataclass +class AccountPower: + bias: int + timestamp: int + slope: int + cumulative_power: int + + @property + def lock_end_timestamp(self): + lock_duration = self.bias * TWO_TO_THE_64 // self.slope + return self.timestamp + lock_duration + + def cumulative_power_at(self, timestamp: int) -> int: + from tinyman.governance.vault.utils import get_cumulative_power_delta + + time_delta = timestamp - self.timestamp + assert time_delta >= 0 + cumulative_power_delta = get_cumulative_power_delta(self.bias, self.slope, time_delta) + return self.cumulative_power + cumulative_power_delta + + +@dataclass +class TotalPower: + bias: int + timestamp: int + slope: int + cumulative_power: int + + +@dataclass +class SlopeChange: + slope_delta: Optional[int] + + +def get_account_state_box_name(address: str) -> bytes: + return decode_address(address) + + +def get_total_power_box_name(box_index: int) -> bytes: + return TOTAL_POWERS + int_to_bytes(box_index) + + +def get_account_power_box_name(address: str, box_index: int) -> bytes: + return decode_address(address) + int_to_bytes(box_index) + + +def get_slope_change_box_name(timestamp: int) -> bytes: + return SLOPE_CHANGES + int_to_bytes(timestamp) + + +def parse_box_account_state(raw_box: bytes) -> AccountState: + return AccountState( + locked_amount=bytes_to_int(raw_box[:8]), + lock_end_time=bytes_to_int(raw_box[8:16]), + power_count=bytes_to_int(raw_box[16:24]), + deleted_power_count=bytes_to_int(raw_box[24:32]), + ) + + +def parse_box_account_power(raw_box: bytes) -> list[AccountPower]: + box_size = ACCOUNT_POWER_SIZE + rows = [raw_box[i:i + box_size] for i in range(0, len(raw_box), box_size)] + powers = [] + for row in rows: + if row == (b'\x00' * box_size): + break + + powers.append( + AccountPower( + bias=bytes_to_int(row[:8]), + timestamp=bytes_to_int(row[8:16]), + slope=bytes_to_int(row[16:32]), + cumulative_power=bytes_to_int(row[32:48]), + ) + ) + return powers + + +def parse_box_total_power(raw_box: bytes) -> list[TotalPower]: + box_size = TOTAL_POWER_SIZE + rows = [raw_box[i:i + box_size] for i in range(0, len(raw_box), box_size)] + powers = [] + for row in rows: + if row == (b'\x00' * box_size): + break + + powers.append( + TotalPower( + bias=bytes_to_int(row[:8]), + timestamp=bytes_to_int(row[8:16]), + slope=bytes_to_int(row[16:32]), + cumulative_power=bytes_to_int(row[32:48]), + ) + ) + return powers + + +def parse_box_slope_change(raw_box: bytes) -> SlopeChange: + return SlopeChange( + slope_delta=bytes_to_int(raw_box[:16]), + ) + + +def get_account_state(algod, app_id: int, address: str) -> Optional[AccountState]: + box_name = get_account_state_box_name(address=address) + raw_box = get_raw_box_value(algod, app_id, box_name) + if not raw_box: + return None + return parse_box_account_state(raw_box) + + +def get_account_powers(algod, app_id, address: str, power_count: int, deleted_power_count: int): + box_count = math.ceil(power_count / ACCOUNT_POWER_BOX_ARRAY_LEN) + deleted_box_count = deleted_power_count // ACCOUNT_POWER_BOX_ARRAY_LEN + + account_powers = [] + for box_index in range(deleted_box_count, box_count): + box_name = get_account_power_box_name(address=address, box_index=box_index) + raw_box = get_raw_box_value(algod, app_id, box_name) + account_powers.extend(parse_box_account_power(raw_box)) + return account_powers + + +def get_total_powers(algod, app_id: int, box_index: int) -> list[TotalPower]: + box_name = get_total_power_box_name(box_index=box_index) + box_value = get_raw_box_value(algod, app_id, box_name) + total_powers = parse_box_total_power(box_value) + return total_powers + + +def get_all_total_powers(algod, app_id: int, total_power_count: int) -> list[TotalPower]: + box_count = math.ceil(total_power_count / TOTAL_POWER_BOX_ARRAY_LEN) + immutable_box_count = total_power_count // TOTAL_POWER_BOX_ARRAY_LEN + + total_powers = [] + for box_index in range(box_count): + box_name = get_total_power_box_name(box_index=box_index) + is_box_immutable = box_index < immutable_box_count + raw_box = get_raw_box_value(algod, app_id, box_name, cache=is_box_immutable) + total_powers.extend(parse_box_total_power(raw_box)) + return total_powers + + +def get_slope_change(algod, app_id: int, timestamp: int) -> Optional[SlopeChange]: + box_name = get_slope_change_box_name(timestamp=timestamp) + raw_box = get_raw_box_value(algod, app_id, box_name) + if raw_box is None: + return None + return parse_box_slope_change(raw_box) + + +def get_last_total_powers_indexes(total_power_count: int) -> Tuple[int, int]: + last_index = total_power_count - 1 + box_index = last_index // TOTAL_POWER_BOX_ARRAY_LEN + array_index = last_index % TOTAL_POWER_BOX_ARRAY_LEN + return box_index, array_index + + +def is_total_power_box_full(total_power_count: int) -> bool: + return (total_power_count % TOTAL_POWER_BOX_ARRAY_LEN) == 0 + + +def get_last_account_power_box_indexes(power_count: int) -> Tuple[int, int]: + last_index = power_count - 1 + box_index = last_index // ACCOUNT_POWER_BOX_ARRAY_LEN + array_index = last_index % ACCOUNT_POWER_BOX_ARRAY_LEN + return box_index, array_index + + +def is_account_power_box_full(account_power_count: int) -> bool: + return (account_power_count % TOTAL_POWER_BOX_ARRAY_LEN) == 0 + + +def get_power_index_at(powers: Union[list[AccountPower], list[TotalPower]], timestamp: int) -> Optional[int]: + power_index = None + + for index, power in enumerate(powers): + if timestamp >= power.timestamp: + power_index = index + else: + break + + return power_index diff --git a/tinyman/governance/vault/transactions.py b/tinyman/governance/vault/transactions.py new file mode 100644 index 0000000..c367f88 --- /dev/null +++ b/tinyman/governance/vault/transactions.py @@ -0,0 +1,770 @@ +import math +import time +from typing import Optional + +from algosdk import transaction +from algosdk.encoding import decode_address +from algosdk.logic import get_application_address + +from tinyman.compat import SuggestedParams +from tinyman.constants import MAX_APP_PROGRAM_COST, MAX_APP_TOTAL_TXN_REFERENCES +from tinyman.governance.constants import WEEK +from tinyman.governance.transactions import _prepare_budget_increase_transaction, _prepare_get_box_transaction +from tinyman.governance.vault.exceptions import InsufficientLockAmount, InvalidLockEndTime +from tinyman.governance.vault.storage import get_total_power_box_name, get_account_state_box_name, get_account_power_box_name, get_slope_change_box_name, get_power_index_at, \ + VaultAppGlobalState, AccountState, TotalPower, AccountPower, SlopeChange +from tinyman.governance.vault.constants import VAULT_APP_MINIMUM_BALANCE_REQUIREMENT, TOTAL_POWER_BOX_ARRAY_LEN, ACCOUNT_POWER_BOX_ARRAY_LEN, ACCOUNT_STATE_BOX_COST, \ + SLOPE_CHANGE_BOX_COST, ACCOUNT_POWER_BOX_COST, TOTAL_POWER_BOX_COST, MIN_LOCK_AMOUNT, MIN_LOCK_AMOUNT_INCREMENT, INIT_APP_ARGUMENT, CREATE_LOCK_APP_ARGUMENT, \ + CREATE_CHECKPOINTS_APP_ARGUMENT, INCREASE_LOCK_AMOUNT_APP_ARGUMENT, EXTEND_LOCK_END_TIME_APP_ARGUMENT, WITHDRAW_APP_ARGUMENT, GET_TINY_POWER_OF_APP_ARGUMENT, \ + GET_TINY_POWER_OF_AT_APP_ARGUMENT, GET_TOTAL_TINY_POWER_APP_ARGUMENT, GET_TOTAL_TINY_POWER_AT_APP_ARGUMENT, GET_TOTAL_CUMULATIVE_POWER_AT_APP_ARGUMENT, GET_CUMULATIVE_POWER_OF_AT_APP_ARGUMENT, \ + GET_ACCOUNT_CUMULATIVE_POWER_DELTA_APP_PREFIX, GET_TOTAL_CUMULATIVE_POWER_DELTA_APP_PREFIX,DELETE_ACCOUNT_POWER_BOXES_APP_ARGUMENT, DELETE_ACCOUNT_STATE_APP_ARGUMENT +from tinyman.governance.vault.utils import get_new_total_power_timestamps +from tinyman.utils import TransactionGroup + + +def prepare_init_transactions( + vault_app_id: int, + tiny_asset_id: int, + sender: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + """ + This method must be called once to initialize the checkpoints and opt-in to TINY after the deployment. + """ + + total_powers_box_name = get_total_power_box_name(box_index=0) + boxes = [ + (vault_app_id, total_powers_box_name), + ] + txns = [ + transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(vault_app_id), + amt=VAULT_APP_MINIMUM_BALANCE_REQUIREMENT, + ), + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[ + INIT_APP_ARGUMENT, + ], + foreign_assets=[ + tiny_asset_id + ], + boxes=boxes, + note=app_call_note, + ), + _prepare_budget_increase_transaction(sender, sp=suggested_params, index=vault_app_id), + ] + txns[1].fee *= 2 # opt-in inner txn + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_create_lock_transactions( + vault_app_id: int, + tiny_asset_id: int, + sender: str, + locked_amount: int, + lock_end_time: int, + vault_app_global_state: VaultAppGlobalState, + account_state: Optional[AccountState], + slope_change_at_lock_end_time: Optional[SlopeChange], + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + if locked_amount < MIN_LOCK_AMOUNT: + raise InsufficientLockAmount() + + if lock_end_time % WEEK: + raise InvalidLockEndTime() + + # Boxes + account_state_box_name = get_account_state_box_name(address=sender) + last_total_power_box_name = get_total_power_box_name(box_index=vault_app_global_state.last_total_power_box_index) + next_total_power_box_name = get_total_power_box_name(box_index=vault_app_global_state.last_total_power_box_index + 1) + boxes = [ + (vault_app_id, account_state_box_name), + (vault_app_id, last_total_power_box_name), + # Always pass the next total power box ref, other transactions may increase the total power count. + (vault_app_id, next_total_power_box_name), + ] + if account_state is None: + account_power_box_name = get_account_power_box_name(address=sender, box_index=0) + boxes.append((vault_app_id, account_power_box_name)) + else: + last_account_power_box_name = get_account_power_box_name(address=sender, box_index=account_state.last_account_power_box_index) + boxes.append((vault_app_id, last_account_power_box_name)) + + if not account_state.free_account_power_space_count: + next_account_power_box_name = get_account_power_box_name(address=sender, box_index=account_state.last_account_power_box_index + 1) + boxes.append((vault_app_id, next_account_power_box_name)) + + # slope change will be updated or created for lock end time + slope_change_box_name = get_slope_change_box_name(timestamp=lock_end_time) + boxes.append((vault_app_id, slope_change_box_name)) + + # contract will create weekly checkpoints automatically + new_total_power_timestamps = get_new_total_power_timestamps(vault_app_global_state.last_total_power_timestamp, int(time.time())) + new_total_power_count = len(new_total_power_timestamps) + for timestamp in new_total_power_timestamps: + if timestamp % WEEK == 0: + slope_change_box_name = get_slope_change_box_name(timestamp=timestamp) + boxes.append((vault_app_id, slope_change_box_name)) + + # Min Balance + min_balance_increase = 0 + if account_state is None: + min_balance_increase += ACCOUNT_STATE_BOX_COST + min_balance_increase += ACCOUNT_POWER_BOX_COST + else: + if not account_state.free_account_power_space_count: + min_balance_increase += ACCOUNT_POWER_BOX_COST + + if new_total_power_count > vault_app_global_state.free_total_power_space_count: + min_balance_increase += TOTAL_POWER_BOX_COST + + if slope_change_at_lock_end_time is None: + min_balance_increase += SLOPE_CHANGE_BOX_COST + + txns = [ + transaction.AssetTransferTxn( + index=tiny_asset_id, + sender=sender, + receiver=get_application_address(vault_app_id), + amt=locked_amount, + sp=suggested_params, + ), + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[ + CREATE_LOCK_APP_ARGUMENT, + lock_end_time, + ], + boxes=boxes[:MAX_APP_TOTAL_TXN_REFERENCES], + note=app_call_note + ), + _prepare_budget_increase_transaction(sender, sp=suggested_params, index=vault_app_id, boxes=boxes[MAX_APP_TOTAL_TXN_REFERENCES:]) + ] + + if min_balance_increase: + minimum_balance_payment = transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(vault_app_id), + amt=min_balance_increase, + ) + txns.insert(0, minimum_balance_payment) + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_create_checkpoints_transactions( + vault_app_id: int, + sender: str, + vault_app_global_state: VaultAppGlobalState, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +): + # Boxes + boxes = [ + (vault_app_id, get_total_power_box_name(box_index=vault_app_global_state.last_total_power_box_index)), + (vault_app_id, get_total_power_box_name(box_index=vault_app_global_state.last_total_power_box_index + 1)), + ] + + new_total_power_timestamps = get_new_total_power_timestamps(vault_app_global_state.last_total_power_timestamp, int(time.time())) + new_total_power_timestamps = new_total_power_timestamps[:9] # a contract call can create maximum 9 total_powers + new_total_power_count = len(new_total_power_timestamps) + + for timestamp in new_total_power_timestamps: + if timestamp % WEEK == 0: + slope_change_box_name = get_slope_change_box_name(timestamp=timestamp) + boxes.append((vault_app_id, slope_change_box_name)) + + # Opcode Budget + # 103 flat budget + 285 per iteration + required_opcode_budget = (103 + new_total_power_count * 285) + # Increase budget app call consumes 14 budget + increase_txn_count = math.ceil((required_opcode_budget - MAX_APP_PROGRAM_COST) / 686) + + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[ + CREATE_CHECKPOINTS_APP_ARGUMENT, + ], + boxes=boxes[:MAX_APP_TOTAL_TXN_REFERENCES], + note=app_call_note + ), + *[_prepare_budget_increase_transaction(sender, sp=suggested_params, index=vault_app_id, boxes=boxes[(i * MAX_APP_TOTAL_TXN_REFERENCES): (i * MAX_APP_TOTAL_TXN_REFERENCES) + MAX_APP_TOTAL_TXN_REFERENCES]) for i in range(1, increase_txn_count + 1)], + ] + + # Min Balance + if new_total_power_count > vault_app_global_state.free_total_power_space_count: + minimum_balance_payment = transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(vault_app_id), + amt=TOTAL_POWER_BOX_COST + ) + txns.insert(0, minimum_balance_payment) + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_increase_lock_amount_transactions( + vault_app_id: int, + tiny_asset_id: int, + sender: str, + locked_amount: int, + vault_app_global_state: VaultAppGlobalState, + account_state: AccountState, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +): + if locked_amount < MIN_LOCK_AMOUNT_INCREMENT: + raise InsufficientLockAmount() + + # Boxes + account_state_box_name = get_account_state_box_name(address=sender) + account_power_box_name = get_account_power_box_name(address=sender, box_index=account_state.last_account_power_box_index) + total_powers_box_name = get_total_power_box_name(box_index=vault_app_global_state.last_total_power_box_index) + next_total_powers_box_name = get_total_power_box_name(box_index=vault_app_global_state.last_total_power_box_index + 1) + slope_change_box_name = get_slope_change_box_name(timestamp=account_state.lock_end_time) + boxes = [ + (vault_app_id, account_state_box_name), + (vault_app_id, account_power_box_name), + (vault_app_id, total_powers_box_name), + (vault_app_id, next_total_powers_box_name), + (vault_app_id, slope_change_box_name), + ] + + # contract will create weekly checkpoints automatically + new_total_power_timestamps = get_new_total_power_timestamps(vault_app_global_state.last_total_power_timestamp, int(time.time())) + new_total_power_count = len(new_total_power_timestamps) + for timestamp in new_total_power_timestamps: + if timestamp % WEEK == 0: + slope_change_box_name = get_slope_change_box_name(timestamp=timestamp) + boxes.append((vault_app_id, slope_change_box_name)) + + # Min balance + min_balance_increase = 0 + if new_total_power_count > vault_app_global_state.free_total_power_space_count: + min_balance_increase += TOTAL_POWER_BOX_COST + + if not account_state.free_account_power_space_count: + new_account_power_box_name = get_account_power_box_name(address=sender, box_index=account_state.last_account_power_box_index + 1) + boxes.append((vault_app_id, new_account_power_box_name)) + min_balance_increase += ACCOUNT_POWER_BOX_COST + + txns = [ + transaction.AssetTransferTxn( + index=tiny_asset_id, + sender=sender, + receiver=get_application_address(vault_app_id), + amt=locked_amount, + sp=suggested_params, + ), + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[ + INCREASE_LOCK_AMOUNT_APP_ARGUMENT, + ], + boxes=boxes, + note=app_call_note + ), + _prepare_budget_increase_transaction(sender, sp=suggested_params, index=vault_app_id), + ] + + if min_balance_increase: + minimum_balance_payment = transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(vault_app_id), + amt=min_balance_increase, + ) + txns.insert(0, minimum_balance_payment) + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_extend_lock_end_time_transactions( + vault_app_id: int, + sender: str, + new_lock_end_time: int, + vault_app_global_state: VaultAppGlobalState, + account_state: AccountState, + slope_change_at_new_lock_end_time: Optional[int], + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +): + if new_lock_end_time % WEEK: + raise InvalidLockEndTime() + + # Boxes + account_state_box_name = decode_address(sender) + account_power_box_name = get_account_power_box_name(address=sender, box_index=account_state.last_account_power_box_index) + total_powers_box_name = get_total_power_box_name(box_index=vault_app_global_state.last_total_power_box_index) + next_total_powers_box_name = get_total_power_box_name(box_index=vault_app_global_state.last_total_power_box_index + 1) + current_account_slope_change_box_name = get_slope_change_box_name(timestamp=account_state.lock_end_time) + new_account_slope_change_box_name = get_slope_change_box_name(timestamp=new_lock_end_time) + boxes = [ + (vault_app_id, account_state_box_name), + (vault_app_id, account_power_box_name), + (vault_app_id, total_powers_box_name), + (vault_app_id, next_total_powers_box_name), + (vault_app_id, current_account_slope_change_box_name), + (vault_app_id, new_account_slope_change_box_name), + ] + + if not account_state.free_account_power_space_count: + new_account_power_box_name = get_account_power_box_name(address=sender, box_index=account_state.last_account_power_box_index + 1) + boxes.append((vault_app_id, new_account_power_box_name)) + + # contract will create weekly checkpoints automatically + new_total_power_timestamps = get_new_total_power_timestamps(vault_app_global_state.last_total_power_timestamp, int(time.time())) + new_total_power_count = len(new_total_power_timestamps) + for timestamp in new_total_power_timestamps: + if timestamp % WEEK == 0: + slope_change_box_name = get_slope_change_box_name(timestamp=timestamp) + boxes.append((vault_app_id, slope_change_box_name)) + + # Min Balance + min_balance_increase = 0 + if slope_change_at_new_lock_end_time is None: + min_balance_increase += SLOPE_CHANGE_BOX_COST + + if not account_state.free_account_power_space_count: + min_balance_increase += ACCOUNT_POWER_BOX_COST + + if new_total_power_count > vault_app_global_state.free_total_power_space_count: + min_balance_increase += TOTAL_POWER_BOX_COST + + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[ + EXTEND_LOCK_END_TIME_APP_ARGUMENT, + new_lock_end_time + ], + boxes=boxes[:MAX_APP_TOTAL_TXN_REFERENCES], + note=app_call_note + ), + _prepare_budget_increase_transaction(sender, sp=suggested_params, index=vault_app_id, boxes=boxes[MAX_APP_TOTAL_TXN_REFERENCES:]), + ] + + if min_balance_increase: + minimum_balance_payment = transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(vault_app_id), + amt=min_balance_increase, + ) + txns.insert(0, minimum_balance_payment) + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_withdraw_transactions( + vault_app_id: int, + tiny_asset_id: int, + sender: str, + account_state: AccountState, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +): + # Boxes + account_state_box_name = decode_address(sender) + account_power_box_name = get_account_power_box_name(address=sender, box_index=account_state.last_account_power_box_index) + next_account_power_box_name = get_account_power_box_name(address=sender, box_index=account_state.last_account_power_box_index + 1) + boxes = [ + (vault_app_id, account_state_box_name), + (vault_app_id, account_power_box_name), + (vault_app_id, next_account_power_box_name), + ] + + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[WITHDRAW_APP_ARGUMENT], + foreign_assets=[tiny_asset_id], + boxes=boxes, + note=app_call_note + ) + ] + txns[0].fee *= 2 + + # Min Balance + if not account_state.free_account_power_space_count: + min_balance_increase = ACCOUNT_POWER_BOX_COST + minimum_balance_payment = transaction.PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=get_application_address(vault_app_id), + amt=min_balance_increase, + ) + txns.insert(0, minimum_balance_payment) + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_get_tiny_power_of_transactions( + vault_app_id: int, + sender: str, + user_address: str, + suggested_params: SuggestedParams, +): + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[GET_TINY_POWER_OF_APP_ARGUMENT, decode_address(user_address)], + boxes=[ + (vault_app_id, decode_address(user_address)), + ] + ) + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_get_tiny_power_of_at_transactions( + vault_app_id: int, + sender: str, + user_address: str, + user_account_powers: list[AccountPower], + timestamp: int, + suggested_params: SuggestedParams, +): + account_power_index = get_power_index_at(user_account_powers, timestamp) + if account_power_index is None: + account_power_index = 0 + account_power_box_index = 0 + else: + account_power_box_index = account_power_index // ACCOUNT_POWER_BOX_ARRAY_LEN + + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[GET_TINY_POWER_OF_AT_APP_ARGUMENT, decode_address(user_address), timestamp, account_power_index], + boxes=[ + (vault_app_id, decode_address(user_address)), + (vault_app_id, get_account_power_box_name(address=user_address, box_index=account_power_box_index)), + (vault_app_id, get_account_power_box_name(address=user_address, box_index=account_power_box_index + 1)), + ] + ) + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_get_account_cumulative_power_delta_transactions( + vault_app_id: int, + sender: str, + user_address: str, + user_account_powers: list[AccountPower], + timestamp_1: int, + timestamp_2: int, + suggested_params: SuggestedParams, +): + account_power_index_1 = get_power_index_at(user_account_powers, timestamp_1) + if account_power_index_1 is None: + account_power_index_1 = 0 + account_power_box_index_1 = 0 + else: + account_power_box_index_1 = account_power_index_1 // ACCOUNT_POWER_BOX_ARRAY_LEN + + account_power_index_2 = get_power_index_at(user_account_powers, timestamp_2) + if account_power_index_2 is None: + account_power_index_2 = 0 + account_power_box_index_2 = 0 + else: + account_power_box_index_2 = account_power_index_2 // ACCOUNT_POWER_BOX_ARRAY_LEN + + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[GET_ACCOUNT_CUMULATIVE_POWER_DELTA_APP_PREFIX, decode_address(user_address), timestamp_1, timestamp_2, account_power_index_1, account_power_index_2], + boxes=[ + (vault_app_id, decode_address(user_address)), + (vault_app_id, get_account_power_box_name(address=user_address, box_index=account_power_box_index_1)), + (vault_app_id, get_account_power_box_name(address=user_address, box_index=account_power_box_index_1 + 1)), + (vault_app_id, get_account_power_box_name(address=user_address, box_index=account_power_box_index_2)), + (vault_app_id, get_account_power_box_name(address=user_address, box_index=account_power_box_index_2 + 1)), + ] + ) + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_get_total_tiny_power_transactions( + vault_app_id: int, + sender: str, + vault_app_global_state: VaultAppGlobalState, + suggested_params: SuggestedParams, +): + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[GET_TOTAL_TINY_POWER_APP_ARGUMENT], + boxes=[ + (vault_app_id, get_total_power_box_name(box_index=vault_app_global_state.last_total_power_box_index)), + ] + ) + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_get_total_tiny_power_of_at_transactions( + vault_app_id: int, + sender: str, + timestamp: int, + total_powers: list[TotalPower], + suggested_params: SuggestedParams, +): + total_power_index = get_power_index_at(total_powers, timestamp) + if total_power_index is None: + total_power_index = 0 + total_power_box_index = 0 + else: + total_power_box_index = total_power_index // TOTAL_POWER_BOX_ARRAY_LEN + + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[GET_TOTAL_TINY_POWER_AT_APP_ARGUMENT, timestamp, total_power_index], + boxes=[ + (vault_app_id, get_total_power_box_name(box_index=total_power_box_index)), + (vault_app_id, get_total_power_box_name(box_index=total_power_box_index + 1)), + ] + ) + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_get_total_cumulative_power_at_transactions( + vault_app_id: int, + sender: str, + timestamp: int, + total_powers: list[TotalPower], + suggested_params: SuggestedParams, +): + total_power_index = get_power_index_at(total_powers, timestamp) + if total_power_index is None: + total_power_index = 0 + total_power_box_index = 0 + else: + total_power_box_index = total_power_index // TOTAL_POWER_BOX_ARRAY_LEN + + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[GET_TOTAL_CUMULATIVE_POWER_AT_APP_ARGUMENT, timestamp, total_power_index], + boxes=[ + (vault_app_id, get_total_power_box_name(box_index=total_power_box_index)), + (vault_app_id, get_total_power_box_name(box_index=total_power_box_index + 1)), + ] + ) + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_get_total_cumulative_power_delta_transactions( + vault_app_id: int, + sender: str, + timestamp_1: int, + timestamp_2: int, + total_powers: list[TotalPower], + suggested_params: SuggestedParams, +): + total_power_index_1 = get_power_index_at(total_powers, timestamp_1) + if total_power_index_1 is None: + total_power_index_1 = 0 + total_power_box_index_1 = 0 + else: + total_power_box_index_1 = total_power_index_1 // TOTAL_POWER_BOX_ARRAY_LEN + + total_power_index_2 = get_power_index_at(total_powers, timestamp_2) + if total_power_index_2 is None: + total_power_index_2 = 0 + total_power_box_index_2 = 0 + else: + total_power_box_index_2 = total_power_index_2 // TOTAL_POWER_BOX_ARRAY_LEN + + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[GET_TOTAL_CUMULATIVE_POWER_DELTA_APP_PREFIX, timestamp_1, timestamp_2, total_power_index_1, total_power_index_2], + boxes=[ + (vault_app_id, get_total_power_box_name(box_index=total_power_box_index_1)), + (vault_app_id, get_total_power_box_name(box_index=total_power_box_index_1 + 1)), + (vault_app_id, get_total_power_box_name(box_index=total_power_box_index_2)), + (vault_app_id, get_total_power_box_name(box_index=total_power_box_index_2 + 1)), + ] + ) + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_get_cumulative_power_of_at_transactions( + vault_app_id: int, + sender: str, + user_address: str, + user_account_powers: list[AccountPower], + timestamp: int, + suggested_params: SuggestedParams, +): + account_power_index = get_power_index_at(user_account_powers, timestamp) + if account_power_index is None: + account_power_index = 0 + account_power_box_index = 0 + else: + account_power_box_index = account_power_index // ACCOUNT_POWER_BOX_ARRAY_LEN + + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[GET_CUMULATIVE_POWER_OF_AT_APP_ARGUMENT, decode_address(user_address), timestamp, account_power_index], + boxes=[ + (vault_app_id, decode_address(user_address)), + (vault_app_id, get_account_power_box_name(address=user_address, box_index=account_power_box_index)), + (vault_app_id, get_account_power_box_name(address=user_address, box_index=account_power_box_index + 1)), + ] + ) + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_delete_account_power_boxes_transactions( + vault_app_id: int, + sender: str, + account_state: AccountState, + box_count: int, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + assert box_count <= 127 + + box_index_start = account_state.deleted_power_count // ACCOUNT_POWER_BOX_ARRAY_LEN + account_state_box_name = get_account_state_box_name(address=sender) + + account_power_boxes = list() + for i in range(box_count): + box_index = box_index_start + i + box_name = get_account_power_box_name(address=sender, box_index=box_index) + account_power_boxes.append((vault_app_id, box_name)) + + boxes = [ + (vault_app_id, account_state_box_name), + *account_power_boxes, + ] + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[DELETE_ACCOUNT_POWER_BOXES_APP_ARGUMENT, box_count], + boxes=boxes[:MAX_APP_TOTAL_TXN_REFERENCES], + note=app_call_note, + ), + ] + txns[0].fee *= 2 + + remaining_boxes = boxes[MAX_APP_TOTAL_TXN_REFERENCES:] + for boxes_chunk in [remaining_boxes[i:i + MAX_APP_TOTAL_TXN_REFERENCES] for i in range(0, len(remaining_boxes), MAX_APP_TOTAL_TXN_REFERENCES)]: + txns.append(_prepare_budget_increase_transaction(sender, sp=suggested_params, index=vault_app_id, boxes=boxes_chunk)) + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_delete_account_state_transactions( + vault_app_id: int, + sender: str, + account_state: AccountState, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + + box_index = account_state.deleted_power_count // ACCOUNT_POWER_BOX_ARRAY_LEN + account_state_box_name = get_account_state_box_name(address=sender) + + deleted_power_count = account_state.deleted_power_count + + account_power_boxes = list() + while deleted_power_count < account_state.power_count: + box_name = get_account_power_box_name(address=sender, box_index=box_index) + account_power_boxes.append((vault_app_id, box_name)) + + box_index += 1 + deleted_power_count += ACCOUNT_POWER_BOX_ARRAY_LEN + + boxes = [ + (vault_app_id, account_state_box_name), + *account_power_boxes, + ] + txns = [ + transaction.ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=vault_app_id, + app_args=[DELETE_ACCOUNT_STATE_APP_ARGUMENT], + boxes=boxes[:MAX_APP_TOTAL_TXN_REFERENCES], + note=app_call_note, + ), + ] + txns[0].fee *= 2 + + remaining_boxes = boxes[MAX_APP_TOTAL_TXN_REFERENCES:] + for boxes_chunk in [remaining_boxes[i:i + MAX_APP_TOTAL_TXN_REFERENCES] for i in range(0, len(remaining_boxes), MAX_APP_TOTAL_TXN_REFERENCES)]: + txns.append(_prepare_budget_increase_transaction(sender, sp=suggested_params, index=vault_app_id, boxes=boxes_chunk)) + + assert len(txns) <= 16, "delete account powers first" + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_get_box_transaction( + vault_app_id: int, + **kwargs +) -> TransactionGroup: + return _prepare_get_box_transaction(app_id=vault_app_id, **kwargs) diff --git a/tinyman/governance/vault/utils.py b/tinyman/governance/vault/utils.py new file mode 100644 index 0000000..65c7457 --- /dev/null +++ b/tinyman/governance/vault/utils.py @@ -0,0 +1,56 @@ +from tinyman.governance.constants import WEEK +from tinyman.governance.vault.constants import TWO_TO_THE_64, MAX_LOCK_TIME + + +def get_slope(locked_amount): + return locked_amount * TWO_TO_THE_64 // MAX_LOCK_TIME + + +def get_bias(slope, time_delta): + assert time_delta >= 0 + return (slope * time_delta) // TWO_TO_THE_64 + + +def get_start_timestamp_of_week(value): + return (value // WEEK) * WEEK + + +def get_cumulative_power_delta(bias: int, slope: int, time_delta: int) -> int: + bias_delta = get_bias(slope, time_delta) + + if bias_delta > bias: + if slope: + cumulative_power_delta = ((bias * bias) * TWO_TO_THE_64) // (slope * 2) + else: + cumulative_power_delta = 0 + else: + new_bias = bias - bias_delta + cumulative_power_delta = (bias + new_bias) * time_delta // 2 + return cumulative_power_delta + + +def get_cumulative_power(old_bias: int, new_bias: int, time_delta: int) -> int: + """Calculate the cumulative power between two biases over a time period. Reference: vault_approval.get_cumulative_power_1()""" + return (old_bias + new_bias) * time_delta // 2 + + +def get_cumulative_power_2(bias: int, slope: int): + """Calculate the cumulative power through the end of lock. Reference: vault_approval.get_cumulative_power_2()""" + return ((bias * bias) * TWO_TO_THE_64) // (slope * 2) + + +def get_new_total_power_timestamps(old_timestamp, new_timestamp): + assert old_timestamp <= new_timestamp + + timestamps = [] + week_timestamp = get_start_timestamp_of_week(old_timestamp) + WEEK + while week_timestamp < new_timestamp: + timestamps.append(week_timestamp) + week_timestamp += WEEK + timestamps.append(new_timestamp) + + return timestamps + + +def get_new_total_power_count(old_timestamp, new_timestamp): + return len(get_new_total_power_timestamps(old_timestamp, new_timestamp)) diff --git a/tinyman/utils.py b/tinyman/utils.py index 1296966..58995dc 100644 --- a/tinyman/utils.py +++ b/tinyman/utils.py @@ -58,8 +58,8 @@ def sign_and_submit_transactions( return txn_info -def int_to_bytes(num): - return num.to_bytes(8, "big") +def int_to_bytes(num, length=8): + return num.to_bytes(length, "big") def int_list_to_bytes(nums): @@ -89,6 +89,12 @@ def get_state_bytes(state, key): return state.get(key.decode(), {"bytes": ""})["bytes"] +def lpad(string: bytes, n: int) -> bytes: + assert(n > 0) + + return b"\x00" * (n - len(string)) + string + + def apply_delta(state, delta): state = dict(state) for d in delta: @@ -131,7 +137,7 @@ def get_version(tinyman_app_id: int) -> str: def generate_app_call_note( - version: str, client_name: Optional[str] = None, extra_data: Optional[dict] = None + version: str, client_name: Optional[str] = None, extra_data: Optional[dict] = None, dapp_name: str = "tinyman", ) -> str: # https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0002.md # : @@ -151,7 +157,7 @@ def generate_app_call_note( serialized_data = json.dumps(data, separators=(",", ":"), sort_keys=True) note = note_template.format( - dapp_name="tinyman", dapp_version=version, data_format="j", data=serialized_data + dapp_name=dapp_name, dapp_version=version, data_format="j", data=serialized_data ) return note @@ -222,8 +228,10 @@ def sign_with_logicisg(self, logicsig): ) return self.sign_with_logicsig(logicsig) - def sign_with_logicsig(self, logicsig): - address = logicsig.address() + def sign_with_logicsig(self, logicsig, address=None): + if address is None: + address = logicsig.address() + for i, txn in enumerate(self.transactions): if txn.sender == address: self.signed_transactions[i] = LogicSigTransaction(txn, logicsig) @@ -277,3 +285,24 @@ def find_app_id_from_txn_id(transaction_group, txn_id): app_id = txn.index break return app_id + + +def parse_global_state_from_application_info(application_info: dict) -> dict: + raw_global_state = application_info["params"]["global-state"] + + global_state = {} + for pair in raw_global_state: + key = b64decode(pair["key"]).decode() + if pair["value"]["type"] == 1: + value = b64decode(pair["value"].get("bytes", "")) + else: + value = pair["value"].get("uint", 0) + global_state[key] = value + + return global_state + + +def get_global_state(algod, app_id: int) -> dict: + application_info = algod.application_info(app_id) + global_state = parse_global_state_from_application_info(application_info) + return global_state From d2d3d753c20bfe9e59d8f685de895eae07af3dbd Mon Sep 17 00:00:00 2001 From: etzellux Date: Wed, 31 Jul 2024 13:24:23 +0300 Subject: [PATCH 2/2] fix linting --- tinyman/governance/client.py | 2 +- tinyman/governance/vault/constants.py | 2 +- tinyman/governance/vault/transactions.py | 4 ++-- tinyman/utils.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tinyman/governance/client.py b/tinyman/governance/client.py index 5535d6c..b9bf221 100644 --- a/tinyman/governance/client.py +++ b/tinyman/governance/client.py @@ -602,7 +602,7 @@ def prepare_create_proposal_transactions( if required_tiny_power := self.get_required_tiny_power_to_create_proposal(): if account_tiny_power < required_tiny_power: raise InsufficientTinyPower() - + if executor: executor = decode_address(executor) else: diff --git a/tinyman/governance/vault/constants.py b/tinyman/governance/vault/constants.py index 4cc8ca1..fc6756e 100644 --- a/tinyman/governance/vault/constants.py +++ b/tinyman/governance/vault/constants.py @@ -1,5 +1,5 @@ from tinyman.constants import MINIMUM_BALANCE_REQUIREMENT_PER_BOX, MINIMUM_BALANCE_REQUIREMENT_PER_BOX_BYTE -from tinyman.governance.constants import DAY, WEEK +from tinyman.governance.constants import WEEK MIN_LOCK_TIME = 4 * WEEK # 4 WEEK MAX_LOCK_TIME = 4 * 52 * WEEK # 364 * 4 Days diff --git a/tinyman/governance/vault/transactions.py b/tinyman/governance/vault/transactions.py index c367f88..940fd4e 100644 --- a/tinyman/governance/vault/transactions.py +++ b/tinyman/governance/vault/transactions.py @@ -17,7 +17,7 @@ SLOPE_CHANGE_BOX_COST, ACCOUNT_POWER_BOX_COST, TOTAL_POWER_BOX_COST, MIN_LOCK_AMOUNT, MIN_LOCK_AMOUNT_INCREMENT, INIT_APP_ARGUMENT, CREATE_LOCK_APP_ARGUMENT, \ CREATE_CHECKPOINTS_APP_ARGUMENT, INCREASE_LOCK_AMOUNT_APP_ARGUMENT, EXTEND_LOCK_END_TIME_APP_ARGUMENT, WITHDRAW_APP_ARGUMENT, GET_TINY_POWER_OF_APP_ARGUMENT, \ GET_TINY_POWER_OF_AT_APP_ARGUMENT, GET_TOTAL_TINY_POWER_APP_ARGUMENT, GET_TOTAL_TINY_POWER_AT_APP_ARGUMENT, GET_TOTAL_CUMULATIVE_POWER_AT_APP_ARGUMENT, GET_CUMULATIVE_POWER_OF_AT_APP_ARGUMENT, \ - GET_ACCOUNT_CUMULATIVE_POWER_DELTA_APP_PREFIX, GET_TOTAL_CUMULATIVE_POWER_DELTA_APP_PREFIX,DELETE_ACCOUNT_POWER_BOXES_APP_ARGUMENT, DELETE_ACCOUNT_STATE_APP_ARGUMENT + GET_ACCOUNT_CUMULATIVE_POWER_DELTA_APP_PREFIX, GET_TOTAL_CUMULATIVE_POWER_DELTA_APP_PREFIX, DELETE_ACCOUNT_POWER_BOXES_APP_ARGUMENT, DELETE_ACCOUNT_STATE_APP_ARGUMENT from tinyman.governance.vault.utils import get_new_total_power_timestamps from tinyman.utils import TransactionGroup @@ -616,7 +616,7 @@ def prepare_get_total_cumulative_power_delta_transactions( total_power_box_index_1 = 0 else: total_power_box_index_1 = total_power_index_1 // TOTAL_POWER_BOX_ARRAY_LEN - + total_power_index_2 = get_power_index_at(total_powers, timestamp_2) if total_power_index_2 is None: total_power_index_2 = 0 diff --git a/tinyman/utils.py b/tinyman/utils.py index 58995dc..5fc1776 100644 --- a/tinyman/utils.py +++ b/tinyman/utils.py @@ -90,7 +90,7 @@ def get_state_bytes(state, key): def lpad(string: bytes, n: int) -> bytes: - assert(n > 0) + assert (n > 0) return b"\x00" * (n - len(string)) + string