diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6e2f1eb..4698d2c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,8 +9,11 @@ jobs: strategy: matrix: python-version: ["3.11", "3.10", "3.9", "3.8"] - py-algorand-sdk-version: ["1.10.0", "1.11.0", "1.12.0", "1.13.0", "1.13.1", "1.14.0", "1.15.0", "1.16.0", "1.16.1", - "1.17.0", "1.18.0", "1.19.0", "1.20.0", "1.20.1", "1.20.2", "2.0.0"] + py-algorand-sdk-version: [ + "1.10.0", "1.11.0", "1.12.0", "1.13.0", "1.13.1", "1.14.0", "1.15.0", "1.16.0", + "1.16.1","1.17.0", "1.18.0","1.19.0", "1.20.0", "1.20.1", "1.20.2", + "2.0.0", "2.1.0", "2.1.1", "2.1.2" + ] steps: - uses: actions/checkout@v3 @@ -23,7 +26,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 black py-algorand-sdk==${{ matrix.py-algorand-sdk-version }} + pip install flake8 black==23.3.0 py-algorand-sdk==${{ matrix.py-algorand-sdk-version }} requests - name: Run flake8 run: flake8 ${{ github.workspace }} --ignore=E501,F403,F405,E126,E121,W503,E203 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0b32a2e..ee83d68 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: exclude: ^(env|venv) - repo: https://github.com/psf/black - rev: 22.8.0 + rev: 23.3.0 hooks: - id: black args: ['.', '--check'] diff --git a/examples/swap_router/__init__.py b/examples/swap_router/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/swap_router/swap.py b/examples/swap_router/swap.py new file mode 100644 index 0000000..ae4d50d --- /dev/null +++ b/examples/swap_router/swap.py @@ -0,0 +1,206 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from typing import Optional +from urllib.parse import quote_plus + +from examples.v2.utils import get_algod +from tinyman.assets import Asset +from tinyman.assets import AssetAmount +from tinyman.optin import prepare_asset_optin_transactions +from tinyman.swap_router.routes import Route +from tinyman.swap_router.routes import get_best_fixed_input_route +from tinyman.swap_router.swap_router import fetch_best_route_suggestion +from tinyman.swap_router.swap_router import ( + get_swap_router_app_opt_in_required_asset_ids, + prepare_swap_router_asset_opt_in_transaction, +) +from tinyman.v1.client import TinymanClient +from tinyman.v1.pools import Pool as TinymanV1Pool +from tinyman.v2.client import TinymanV2Client +from tinyman.v2.client import TinymanV2TestnetClient +from tinyman.v2.pools import Pool as TinymanV2Pool + + +def fetch_routes( + tinyman_v1_client: Optional[TinymanClient], + tinyman_v2_client: TinymanV2Client, + asset_in: Asset, + asset_out: Asset, + swap_type: str, + amount: int, +) -> "list[Route]": + """ + This is an example route list preparation. + You can build yor own route list according to your needs. + + The list contains; + V1 Direct Route if it exists and has liquidity, + V2 Direct Route if it exists and has liquidity, + V2 Indirect (2 swap) Route provided by Tinyman API. + """ + routes = [] + + if tinyman_v1_client is not None: + # Don't check V1 pool if the client is not provided + v1_pool = TinymanV1Pool( + client=tinyman_v1_client, + asset_a=asset_in, + asset_b=asset_out, + fetch=True, + ) + if v1_pool.exists and v1_pool.issued_liquidity: + v1_direct_route = Route( + asset_in=asset_in, asset_out=asset_out, pools=[v1_pool] + ) + routes.append(v1_direct_route) + + v2_pool = TinymanV2Pool( + client=tinyman_v2_client, + asset_a=asset_in, + asset_b=asset_out, + fetch=True, + ) + if v2_pool.exists and v2_pool.issued_pool_tokens: + v2_direct_route = Route(asset_in=asset_in, asset_out=asset_out, pools=[v2_pool]) + routes.append(v2_direct_route) + + route = fetch_best_route_suggestion( + tinyman_client=tinyman_v2_client, + asset_in=asset_in, + asset_out=asset_out, + swap_type=swap_type, + amount=amount, + ) + if len(route.pools) > 1: + routes.append(route) + + return routes + + +def swap(asset_in_id, asset_out_id, amount, account): + algod = get_algod() + + # tinyman_v1_client = TinymanTestnetClient(algod_client=algod, user_address=account["address"]) + tinyman_v2_client = TinymanV2TestnetClient( + algod_client=algod, user_address=account["address"] + ) + + asset_in = tinyman_v2_client.fetch_asset(asset_in_id) + asset_out = tinyman_v2_client.fetch_asset(asset_out_id) + + asset_amount_in = AssetAmount(asset_in, amount) + routes = fetch_routes( + tinyman_v1_client=None, + tinyman_v2_client=tinyman_v2_client, + asset_in=asset_in, + asset_out=asset_out, + swap_type="fixed-input", + amount=asset_amount_in.amount, + ) + + if routes: + print("Routes:") + for index, route in enumerate(routes, start=1): + print(index, route) + else: + print("There is no route available.") + return + + print("Best Route:") + best_route = get_best_fixed_input_route( + routes=routes, amount_in=asset_amount_in.amount + ) + print(best_route) + + if best_route: + print("Quotes:") + quotes = best_route.get_fixed_input_quotes(amount_in=asset_amount_in.amount) + for index, quote in enumerate(quotes, start=1): + print(index, quote) + else: + print("Couldn't calculate the quotes.") + return + + suggested_params = tinyman_v2_client.algod.suggested_params() + + if len(quotes) > 1: + # Swap Router Flow + print("The best route is single hop swap. Prepare swap router transactions.") + + # 1 - Transfer input to swap router app account + # 2 - Swap router app call + txn_group = best_route.prepare_swap_router_transactions_from_quotes( + quotes=quotes, suggested_params=suggested_params + ) + + # Swap router app account may require to opt in to some assets. + opt_in_required_asset_ids = get_swap_router_app_opt_in_required_asset_ids( + algod_client=tinyman_v2_client.algod, + router_app_id=tinyman_v2_client.router_app_id, + asset_ids=best_route.asset_ids, + ) + if opt_in_required_asset_ids: + opt_in_txn_group = prepare_swap_router_asset_opt_in_transaction( + router_app_id=tinyman_v2_client.router_app_id, + asset_ids=opt_in_required_asset_ids, + user_address=account["address"], + suggested_params=suggested_params, + ) + txn_group = opt_in_txn_group + txn_group + + else: + # Direct Swap Flow + pool = best_route.pools[0] + quote = quotes[0] + + if pool.client.version == "v1": + print(f"The best route is direct swap using {pool}.") + print("V1 swap flow is not handled in this example.") + return + + elif pool.client.version == "v2": + print(f"The best route is direct swap using {pool}.") + txn_group = pool.prepare_swap_transactions_from_quote( + quote=quote, + user_address=account["address"], + suggested_params=suggested_params, + ) + else: + raise NotImplementedError() + + # User account may require to opt in to output asset. + if not tinyman_v2_client.asset_is_opted_in( + asset_id=asset_out_id, user_address=account["address"] + ): + user_opt_in_txn_group = prepare_asset_optin_transactions( + asset_id=asset_out_id, + sender=account["address"], + suggested_params=suggested_params, + ) + txn_group = user_opt_in_txn_group + txn_group + + # Sign + txn_group.sign_with_private_key(account["address"], account["private_key"]) + + # Submit transactions to the network and wait for confirmation + txn_info = tinyman_v2_client.submit(txn_group, wait=True) + print("Transaction Info") + print(txn_info) + + print( + f"\nCheck the transaction group on Algoexplorer: https://testnet.algoexplorer.io/tx/group/{quote_plus(txn_group.id)}" + ) + + +if __name__ == "__main__": + # TODO: Set Account + account = { + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", + } + + # TODO: Set asset ids + asset_in_id, asset_out_id = 0, 21582668 + + swap(asset_in_id, asset_out_id, 1_000_000, account) diff --git a/setup.py b/setup.py index 92ae652..e5446ad 100644 --- a/setup.py +++ b/setup.py @@ -16,9 +16,12 @@ project_urls={ "Source": "https://github.com/tinyman/tinyman-py-sdk", }, - install_requires=["py-algorand-sdk >= 1.10.0"], + install_requires=["py-algorand-sdk >= 1.10.0", "requests >= 2.0.0"], packages=setuptools.find_packages(), python_requires=">=3.8", - package_data={"tinyman.v1": ["asc.json"], "tinyman.v2": ["amm_approval.map.json"]}, + package_data={ + "tinyman.v1": ["asc.json"], + "tinyman.v2": ["amm_approval.map.json", "swap_router_approval.map.json"], + }, include_package_data=True, ) diff --git a/tests/swap_router/__init__.py b/tests/swap_router/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/swap_router/test.py b/tests/swap_router/test.py new file mode 100644 index 0000000..7a30ead --- /dev/null +++ b/tests/swap_router/test.py @@ -0,0 +1,719 @@ +from typing import Optional +from unittest import TestCase +from unittest.mock import ANY, patch + +from algosdk.account import generate_account +from algosdk.encoding import decode_address +from algosdk.logic import get_application_address +from algosdk.v2client.algod import AlgodClient + +from tests import get_suggested_params +from tinyman.assets import Asset, AssetAmount +from tinyman.compat import OnComplete +from tinyman.swap_router.routes import ( + Route, + get_best_fixed_input_route, + get_best_fixed_output_route, +) +from tinyman.swap_router.swap_router import ( + prepare_swap_router_asset_opt_in_transaction, + prepare_swap_router_transactions, + get_swap_router_app_opt_in_required_asset_ids, +) +from tinyman.swap_router.utils import parse_swap_router_event_log +from tinyman.v1.client import TinymanClient +from tinyman.v1.constants import TESTNET_VALIDATOR_APP_ID +from tinyman.v2.client import TinymanV2Client +from tinyman.v2.constants import TESTNET_VALIDATOR_APP_ID_V2 +from tinyman.v2.contracts import get_pool_logicsig as v2_get_pool_logicsig +from tinyman.v2.pools import Pool as V2Pool +from tinyman.v2.quotes import SwapQuote as V2SwapQuote + + +class BaseTestCase(TestCase): + maxDiff = None + + @classmethod + def setUpClass(cls): + cls.VALIDATOR_APP_ID_V1 = TESTNET_VALIDATOR_APP_ID + cls.VALIDATOR_APP_ID_V2 = TESTNET_VALIDATOR_APP_ID_V2 + cls.ROUTER_APP_ID = 987_654 + + @classmethod + def get_tinyman_client(cls, user_address=None): + return TinymanV2Client( + algod_client=AlgodClient("TEST", "https://test.test.network"), + validator_app_id=cls.VALIDATOR_APP_ID_V2, + user_address=user_address, + router_app_id=cls.ROUTER_APP_ID, + staking_app_id=None, + ) + + @classmethod + def get_tinyman_v1_client(cls, user_address=None): + return TinymanClient( + algod_client=AlgodClient("TEST", "https://test.test.network"), + validator_app_id=cls.VALIDATOR_APP_ID_V1, + user_address=user_address, + staking_app_id=None, + ) + + @classmethod + def get_suggested_params(cls): + return get_suggested_params() + + @classmethod + def get_pool_state( + cls, asset_1_id=None, asset_2_id=None, pool_token_asset_id=None, **kwargs + ): + state = { + "asset_1_cumulative_price": 0, + "lock": 0, + "cumulative_price_update_timestamp": 0, + "asset_2_cumulative_price": 0, + "asset_2_protocol_fees": 0, + "asset_1_reserves": 0, + "pool_token_asset_id": pool_token_asset_id, + "asset_1_protocol_fees": 0, + "asset_1_id": asset_1_id, + "asset_2_id": asset_2_id, + "issued_pool_tokens": 0, + "asset_2_reserves": 0, + "protocol_fee_ratio": 6, + "total_fee_share": 30, + } + state.update(**kwargs) + return state + + def setUp(self) -> None: + self.algo = Asset(id=0, name="Algorand", unit_name="Algo", decimals=6) + # Direct Route: Pool + self.asset_in = Asset(id=1, name="Asset In", unit_name="Asset In", decimals=6) + self.asset_out = Asset( + id=2, name="Asset Out", unit_name="Asset Out", decimals=10 + ) + pool_token_asset = Asset(id=3, name="TM", unit_name="TM", decimals=6) + + pool_state = self.get_pool_state( + asset_1_id=self.asset_in.id, + asset_2_id=self.asset_out.id, + pool_token_asset_id=pool_token_asset.id, + issued_pool_tokens=10_000, + ) + pool_logicsig = v2_get_pool_logicsig( + TESTNET_VALIDATOR_APP_ID_V2, self.asset_in.id, self.asset_out.id + ) + self.pool = V2Pool.from_state( + address=pool_logicsig.address(), + state=pool_state, + round_number=100, + client=self.get_tinyman_client(), + ) + + # Indirect Route: Pool 1 - Pool 2 + self.intermediary_asset = Asset(id=4, name="Int", unit_name="Int", decimals=10) + pool_1_token_asset = Asset(id=5, name="TM", unit_name="TM", decimals=6) + pool_1_state = self.get_pool_state( + asset_1_id=self.asset_in.id, + asset_2_id=self.intermediary_asset.id, + pool_token_asset_id=pool_1_token_asset.id, + issued_pool_tokens=10_000, + ) + pool_1_logicsig = v2_get_pool_logicsig( + TESTNET_VALIDATOR_APP_ID_V2, self.asset_in.id, self.intermediary_asset.id + ) + self.pool_1 = V2Pool.from_state( + address=pool_1_logicsig.address(), + state=pool_1_state, + round_number=100, + client=self.get_tinyman_client(), + ) + + pool_2_token_asset = Asset(id=6, name="TM", unit_name="TM", decimals=6) + pool_2_state = self.get_pool_state( + asset_1_id=self.intermediary_asset.id, + asset_2_id=self.asset_out.id, + pool_token_asset_id=pool_2_token_asset.id, + issued_pool_tokens=10_000, + ) + pool_2_logicsig = v2_get_pool_logicsig( + TESTNET_VALIDATOR_APP_ID_V2, self.intermediary_asset.id, self.asset_out.id + ) + self.pool_2 = V2Pool.from_state( + address=pool_2_logicsig.address(), + state=pool_2_state, + round_number=100, + client=self.get_tinyman_client(), + ) + + # Swap 1 -> 2: Price ~= 1/5 + # ID 2 + self.pool.asset_1_reserves = 1_000_000_000_000 + # ID 1 + self.pool.asset_2_reserves = 5_000_000_000_000 + + # Swap 1 -> 2: Price ~= 1/2 * 1/2 = 1/4 + # ID 4 + self.pool_1.asset_1_reserves = 1_000_000_000_000 + # ID 1 + self.pool_1.asset_2_reserves = 2_000_000_000_000 + + # ID 4 + self.pool_2.asset_1_reserves = 2_000_000_000_000 + # ID 2 + self.pool_2.asset_2_reserves = 1_000_000_000_000 + + self.direct_route = Route( + asset_in=self.asset_in, asset_out=self.asset_out, pools=[self.pool] + ) + self.indirect_route = Route( + asset_in=self.asset_in, + asset_out=self.asset_out, + pools=[self.pool_1, self.pool_2], + ) + + def get_mock_account_info( + self, + address: str, + assets: Optional[list] = None, + algo_balance: Optional[int] = None, + min_balance: Optional[int] = None, + ): + if assets is None: + assets = [] + + total_assets_opted_in = len(assets) + + if min_balance is None: + min_balance = 1_000 + min_balance += 100_000 * total_assets_opted_in + + if algo_balance is None: + algo_balance = min_balance + + return { + "address": address, + "amount": algo_balance, + "min-balance": algo_balance, + "total-assets-opted-in": total_assets_opted_in, + # [{'amount': 45682551121, 'asset-id': 10458941, 'is-frozen': False}] + "assets": assets, + } + + +class RouteTestCase(BaseTestCase): + def test_fixed_input_quotes(self): + amount_in = 10_000 + + quotes = self.direct_route.get_fixed_input_quotes( + amount_in=amount_in, slippage=0.05 + ) + self.assertEqual(len(quotes), 1) + self.assertEqual(quotes[0].amount_in.amount, amount_in) + self.assertEqual(quotes[0].amount_out.amount, 1993) + + quotes = self.indirect_route.get_fixed_input_quotes( + amount_in=amount_in, slippage=0.05 + ) + self.assertEqual(len(quotes), 2) + self.assertEqual(quotes[0].amount_in.amount, amount_in) + self.assertEqual(quotes[0].amount_out.amount, 4984) + self.assertEqual(quotes[1].amount_in.amount, 4984) + self.assertEqual(quotes[1].amount_out.amount, 2484) + + def test_fixed_output_quotes(self): + amount_out = 10_000 + + quotes = self.direct_route.get_fixed_output_quotes( + amount_out=amount_out, slippage=0.05 + ) + self.assertEqual(len(quotes), 1) + self.assertEqual(quotes[0].amount_in.amount, 50151) + self.assertEqual(quotes[0].amount_out.amount, amount_out) + + quotes = self.indirect_route.get_fixed_output_quotes( + amount_out=amount_out, slippage=0.05 + ) + self.assertEqual(len(quotes), 2) + self.assertEqual(quotes[0].amount_in.amount, 40243) + self.assertEqual(quotes[0].amount_out.amount, 20061) + self.assertEqual(quotes[1].amount_in.amount, 20061) + self.assertEqual(quotes[1].amount_out.amount, amount_out) + + def test_fixed_input_direct_best_route(self): + # Swap 1 -> 2: Price ~= 1/3 + # ID 2 + self.pool.asset_1_reserves = 1_000_000_000_000 + # ID 1 + self.pool.asset_2_reserves = 3_000_000_000_000 + + self.direct_route = Route( + asset_in=self.asset_in, asset_out=self.asset_out, pools=[self.pool] + ) + routes = [self.direct_route, self.indirect_route] + + amount_in = 10_000 + best_route = get_best_fixed_input_route(routes=routes, amount_in=amount_in) + self.assertEqual(best_route, self.direct_route) + + quotes = best_route.get_fixed_input_quotes(amount_in=amount_in) + last_quote = quotes[-1] + self.assertEqual(last_quote.amount_out.amount, 3323) + + def test_fixed_input_indirect_best_route(self): + routes = [self.direct_route, self.indirect_route] + + amount_in = 10_000 + best_route = get_best_fixed_input_route(routes=routes, amount_in=amount_in) + self.assertEqual(best_route, self.indirect_route) + + quotes = best_route.get_fixed_input_quotes(amount_in=amount_in) + last_quote = quotes[-1] + self.assertEqual(last_quote.amount_out.amount, 2484) + + def test_fixed_output_direct_best_route(self): + # Swap 1 -> 2: Price ~= 1/3 + # ID 2 + self.pool.asset_1_reserves = 1_000_000_000_000 + # ID 1 + self.pool.asset_2_reserves = 3_000_000_000_000 + + self.direct_route = Route( + asset_in=self.asset_in, asset_out=self.asset_out, pools=[self.pool] + ) + routes = [self.direct_route, self.indirect_route] + + amount_out = 10_000 + best_route = get_best_fixed_output_route(routes=routes, amount_out=amount_out) + self.assertEqual(best_route, self.direct_route) + + quotes = best_route.get_fixed_output_quotes(amount_out=amount_out) + first_quote = quotes[0] + self.assertEqual(first_quote.amount_in.amount, 30091) + self.assertEqual(first_quote.amount_out.amount, amount_out) + + def test_fixed_output_indirect_best_route(self): + routes = [self.direct_route, self.indirect_route] + + amount_out = 10_000 + best_route = get_best_fixed_output_route(routes=routes, amount_out=amount_out) + self.assertEqual(best_route, self.indirect_route) + + quotes = best_route.get_fixed_output_quotes(amount_out=amount_out) + first_quote = quotes[0] + self.assertEqual(first_quote.amount_in.amount, 40243) + + +class SwapRouterTransactionsTestCase(BaseTestCase): + def test_prepare_swap_router_asset_opt_in_transaction(self): + sp = self.get_suggested_params() + user_private_key, user_address = generate_account() + asset_ids = [1, 2, 3, 4] + + txn_group = prepare_swap_router_asset_opt_in_transaction( + router_app_id=self.ROUTER_APP_ID, + asset_ids=asset_ids, + user_address=user_address, + suggested_params=sp, + ) + self.assertEqual(len(txn_group.transactions), 1) + + self.assertDictEqual( + dict(txn_group.transactions[0].dictify()), + { + "apaa": [b"asset_opt_in"], + "apan": OnComplete.NoOpOC, + "apas": asset_ids, + "apid": self.ROUTER_APP_ID, + "fee": 1000 + len(asset_ids) * 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(user_address), + "type": "appl", + }, + ) + + def test_fixed_input_prepare_swap_router_transactions(self): + sp = self.get_suggested_params() + user_private_key, user_address = generate_account() + router_app_address = get_application_address(self.ROUTER_APP_ID) + asset_in_amount = 1_000_000 + min_output = 2_000_000 + swap_type = "fixed-input" + + txn_group = prepare_swap_router_transactions( + router_app_id=self.ROUTER_APP_ID, + validator_app_id=self.VALIDATOR_APP_ID_V2, + input_asset_id=self.asset_in.id, + intermediary_asset_id=self.intermediary_asset.id, + output_asset_id=self.asset_out.id, + asset_in_amount=asset_in_amount, + asset_out_amount=min_output, + swap_type=swap_type, + user_address=user_address, + suggested_params=sp, + ) + self.assertEqual(len(txn_group.transactions), 2) + self.assertDictEqual( + dict(txn_group.transactions[0].dictify()), + { + "aamt": 1000000, + "arcv": decode_address(router_app_address), + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(user_address), + "type": "axfer", + "xaid": self.asset_in.id, + }, + ) + self.assertDictEqual( + dict(txn_group.transactions[1].dictify()), + { + "apaa": [b"swap", b"fixed-input", min_output.to_bytes(8, "big")], + "apan": OnComplete.NoOpOC, + "apas": [ + self.asset_in.id, + self.intermediary_asset.id, + self.asset_out.id, + ], + "apat": [ + decode_address(self.pool_1.address), + decode_address(self.pool_2.address), + ], + "apfa": [self.VALIDATOR_APP_ID_V2], + "apid": self.ROUTER_APP_ID, + "fee": 8000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(user_address), + "type": "appl", + }, + ) + + def test_output_input_prepare_swap_router_transactions(self): + sp = self.get_suggested_params() + user_private_key, user_address = generate_account() + router_app_id = 99999 + router_app_address = get_application_address(router_app_id) + asset_in_amount = 1_000_000 + asset_out_amount = 2_000_000 + swap_type = "fixed-output" + + txn_group = prepare_swap_router_transactions( + router_app_id=router_app_id, + validator_app_id=self.VALIDATOR_APP_ID_V2, + input_asset_id=self.asset_in.id, + intermediary_asset_id=self.intermediary_asset.id, + output_asset_id=self.asset_out.id, + asset_in_amount=asset_in_amount, + asset_out_amount=asset_out_amount, + swap_type=swap_type, + user_address=user_address, + suggested_params=sp, + ) + self.assertEqual(len(txn_group.transactions), 2) + self.assertDictEqual( + dict(txn_group.transactions[0].dictify()), + { + "aamt": 1000000, + "arcv": decode_address(router_app_address), + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(user_address), + "type": "axfer", + "xaid": self.asset_in.id, + }, + ) + self.assertDictEqual( + dict(txn_group.transactions[1].dictify()), + { + "apaa": [b"swap", b"fixed-output", asset_out_amount.to_bytes(8, "big")], + "apan": OnComplete.NoOpOC, + "apas": [ + self.asset_in.id, + self.intermediary_asset.id, + self.asset_out.id, + ], + "apat": [ + decode_address(self.pool_1.address), + decode_address(self.pool_2.address), + ], + "apfa": [self.VALIDATOR_APP_ID_V2], + "apid": router_app_id, + "fee": 9000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(user_address), + "type": "appl", + }, + ) + + def test_algo_input_prepare_swap_router_transactions(self): + sp = self.get_suggested_params() + user_private_key, user_address = generate_account() + router_app_id = 99999 + router_app_address = get_application_address(router_app_id) + asset_in_amount = 1_000_000 + min_output = 2_000_000 + swap_type = "fixed-input" + + txn_group = prepare_swap_router_transactions( + router_app_id=router_app_id, + validator_app_id=self.VALIDATOR_APP_ID_V2, + input_asset_id=self.algo.id, + intermediary_asset_id=self.intermediary_asset.id, + output_asset_id=self.asset_out.id, + asset_in_amount=asset_in_amount, + asset_out_amount=min_output, + swap_type=swap_type, + user_address=user_address, + suggested_params=sp, + ) + self.assertEqual(len(txn_group.transactions), 2) + # Pay instead of axfer + self.assertDictEqual( + dict(txn_group.transactions[0].dictify()), + { + "amt": 1000000, + "rcv": decode_address(router_app_address), + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(user_address), + "type": "pay", + }, + ) + self.assertEqual( + txn_group.transactions[1].dictify()["apas"], + [self.algo.id, self.intermediary_asset.id, self.asset_out.id], + ) + self.assertEqual(txn_group.transactions[1].dictify()["type"], "appl") + + def test_indirect_route_prepare_swap_router_transactions_from_quotes(self): + sp = self.get_suggested_params() + user_private_key, user_address = generate_account() + asset_in = AssetAmount(self.asset_in, 1_000_000) + asset_out = AssetAmount(self.asset_out, 2_000_000) + asset_intermediary = AssetAmount(self.intermediary_asset, 9_999_999) + swap_type = "fixed-input" + + route = Route( + asset_in=self.asset_in, + asset_out=self.asset_out, + pools=[self.pool_1, self.pool_2], + ) + + quote_1 = V2SwapQuote( + swap_type=swap_type, + amount_in=asset_in, + amount_out=asset_intermediary, + swap_fees=None, + slippage=0, + price_impact=None, + ) + quote_2 = V2SwapQuote( + swap_type=swap_type, + amount_in=asset_intermediary, + amount_out=asset_out, + swap_fees=None, + slippage=0, + price_impact=None, + ) + quotes = [quote_1, quote_2] + + transfer_input_txn = { + "aamt": asset_in.amount, + "arcv": b"\xd4\xb4\xce\xaa\xc35V\xffg\xfa\xae\xcbz\xd0\x8a\xb3\x8f\x85\x1a\x9e\x06b\x8a\xf4X:\x0b\xae[\x93i\xde", + "fee": ANY, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(user_address), + "type": "axfer", + "xaid": self.asset_in.id, + } + swap_app_call_txn = { + "apaa": [b"swap", b"fixed-input", asset_out.amount.to_bytes(8, "big")], + "apan": OnComplete.NoOpOC, + "apas": [self.asset_in.id, self.intermediary_asset.id, self.asset_out.id], + "apat": [ + decode_address(self.pool_1.address), + decode_address(self.pool_2.address), + ], + "apfa": [self.VALIDATOR_APP_ID_V2], + "apid": self.ROUTER_APP_ID, + "fee": ANY, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(user_address), + "type": "appl", + "note": b'tinyman/v2:j{"origin":"tinyman-py-sdk"}', + } + + txn_group = route.prepare_swap_router_transactions_from_quotes( + quotes=quotes, + user_address=user_address, + suggested_params=sp, + ) + self.assertEqual(len(txn_group.transactions), 2) + self.assertDictEqual( + dict(txn_group.transactions[0].dictify()), transfer_input_txn + ) + self.assertDictEqual( + dict(txn_group.transactions[1].dictify()), swap_app_call_txn + ) + + def test_swap_route_opt_in(self): + sp = self.get_suggested_params() + user_private_key, user_address = generate_account() + router_app_address = get_application_address(self.ROUTER_APP_ID) + algod = AlgodClient("TEST", "https://test.test.network") + + route = Route( + asset_in=self.asset_in, + asset_out=self.asset_out, + pools=[self.pool_1, self.pool_2], + ) + opt_in_app_call_txn = { + "apaa": [b"asset_opt_in"], + "apan": OnComplete.NoOpOC, + "apas": ANY, + "apid": self.ROUTER_APP_ID, + "fee": ANY, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(user_address), + "type": "appl", + } + + # 3 Opt-in + account_info = self.get_mock_account_info( + address=router_app_address, + assets=[], + ) + with patch( + "algosdk.v2client.algod.AlgodClient.account_info", return_value=account_info + ): + opt_in_required_asset_ids = get_swap_router_app_opt_in_required_asset_ids( + algod_client=algod, + router_app_id=self.ROUTER_APP_ID, + asset_ids=route.asset_ids, + ) + self.assertEqual(opt_in_required_asset_ids, opt_in_app_call_txn["apas"]) + + opt_in_txn_group = prepare_swap_router_asset_opt_in_transaction( + router_app_id=self.ROUTER_APP_ID, + asset_ids=opt_in_required_asset_ids, + user_address=user_address, + suggested_params=sp, + ) + + self.assertEqual(len(opt_in_txn_group.transactions), 1) + self.assertDictEqual( + dict(opt_in_txn_group.transactions[0].dictify()), opt_in_app_call_txn + ) + + # 2 Opt-in + account_info = self.get_mock_account_info( + address=router_app_address, + assets=[ + {"amount": 0, "asset-id": self.asset_in.id, "is-frozen": False}, + ], + ) + with patch( + "algosdk.v2client.algod.AlgodClient.account_info", return_value=account_info + ): + opt_in_app_call_txn["apas"] = [ + self.asset_out.id, + self.intermediary_asset.id, + ] + opt_in_app_call_txn["fee"] = 3 * 1000 + + opt_in_required_asset_ids = get_swap_router_app_opt_in_required_asset_ids( + algod_client=algod, + router_app_id=self.ROUTER_APP_ID, + asset_ids=route.asset_ids, + ) + self.assertEqual(opt_in_required_asset_ids, opt_in_app_call_txn["apas"]) + + opt_in_txn_group = prepare_swap_router_asset_opt_in_transaction( + router_app_id=self.ROUTER_APP_ID, + asset_ids=opt_in_required_asset_ids, + user_address=user_address, + suggested_params=sp, + ) + + self.assertEqual(len(opt_in_txn_group.transactions), 1) + self.assertDictEqual( + dict(opt_in_txn_group.transactions[0].dictify()), opt_in_app_call_txn + ) + + # No opt-in + Slippage + account_info = self.get_mock_account_info( + address=router_app_address, + assets=[ + {"amount": 0, "asset-id": self.asset_in.id, "is-frozen": False}, + { + "amount": 0, + "asset-id": self.intermediary_asset.id, + "is-frozen": False, + }, + {"amount": 0, "asset-id": self.asset_out.id, "is-frozen": False}, + ], + ) + with patch( + "algosdk.v2client.algod.AlgodClient.account_info", return_value=account_info + ): + opt_in_required_asset_ids = get_swap_router_app_opt_in_required_asset_ids( + algod_client=algod, + router_app_id=self.ROUTER_APP_ID, + asset_ids=route.asset_ids, + ) + self.assertEqual(opt_in_required_asset_ids, []) + + +class EventLogParserTestCase(TestCase): + def test_bytes_log(self): + raw_log = b"\x81b\xda\x9e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x03\xe8\x00\x00\x00\x00\x00\x00&\xbb" + result = parse_swap_router_event_log(log=raw_log) + self.assertDictEqual( + result, + { + "input_asset_id": 0, + "output_asset_id": 5, + "input_amount": 1000, + "output_amount": 9915, + }, + ) + + def test_string_log(self): + raw_log = "gWLangAAAAAAn5c9AAAAAAQwcrUAAAAAAAGGoAAAAAAAA5u2" + result = parse_swap_router_event_log(log=raw_log) + self.assertDictEqual( + result, + { + "input_asset_id": 10458941, + "output_asset_id": 70283957, + "input_amount": 100000, + "output_amount": 236470, + }, + ) diff --git a/tinyman/client.py b/tinyman/client.py index 40ca95a..682e782 100644 --- a/tinyman/client.py +++ b/tinyman/client.py @@ -13,11 +13,13 @@ def __init__( self, algod_client: AlgodClient, validator_app_id: int, + api_base_url: Optional[str] = None, user_address: Optional[str] = None, staking_app_id: Optional[int] = None, client_name: Optional[str] = None, ): self.algod = algod_client + self.api_base_url = api_base_url self.validator_app_id = validator_app_id self.staking_app_id = staking_app_id self.assets_cache = {} @@ -27,7 +29,9 @@ def __init__( def fetch_pool(self, *args, **kwargs): raise NotImplementedError() - def fetch_asset(self, asset_id): + def fetch_asset(self, asset_id: int): + asset_id = int(asset_id) + if asset_id not in self.assets_cache: asset = Asset(asset_id) asset.fetch(self.algod) @@ -76,6 +80,11 @@ def is_opted_in(self, user_address=None): 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: diff --git a/tinyman/exceptions.py b/tinyman/exceptions.py new file mode 100644 index 0000000..31a17bf --- /dev/null +++ b/tinyman/exceptions.py @@ -0,0 +1,22 @@ +class PoolIsNotBootstrapped(Exception): + pass + + +class PoolAlreadyBootstrapped(Exception): + pass + + +class PoolHasNoLiquidity(Exception): + pass + + +class PoolAlreadyInitialized(Exception): + pass + + +class InsufficientReserves(Exception): + pass + + +class LowSwapAmountError(Exception): + pass diff --git a/tinyman/swap_router/__init__.py b/tinyman/swap_router/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tinyman/swap_router/constants.py b/tinyman/swap_router/constants.py new file mode 100644 index 0000000..3da0e82 --- /dev/null +++ b/tinyman/swap_router/constants.py @@ -0,0 +1,16 @@ +TESTNET_SWAP_ROUTER_APP_ID_V1 = 184778019 +MAINNET_SWAP_ROUTER_APP_ID_V1 = 1083651166 + +FIXED_INPUT_SWAP_TYPE = "fixed-input" +FIXED_OUTPUT_SWAP_TYPE = "fixed-output" + +SWAP_APP_ARGUMENT = b"swap" +FIXED_INPUT_APP_ARGUMENT = b"fixed-input" +FIXED_OUTPUT_APP_ARGUMENT = b"fixed-output" +ASSET_OPT_IN_APP_ARGUMENT = b"asset_opt_in" +CLAIM_EXTRA_APP_ARGUMENT = b"claim_extra" +SET_MANAGER_APP_ARGUMENT = b"set_manager" +SET_EXTRA_COLLECTOR_APP_ARGUMENT = b"set_extra_collector" + +# Event Log Selectors +SWAP_EVENT_LOG_SELECTOR = b"\x81b\xda\x9e" # "swap(uint64,uint64,uint64,uint64)" diff --git a/tinyman/swap_router/management.py b/tinyman/swap_router/management.py new file mode 100644 index 0000000..8ef2dd0 --- /dev/null +++ b/tinyman/swap_router/management.py @@ -0,0 +1,77 @@ +from typing import Optional + +from tinyman.compat import ( + ApplicationNoOpTxn, + SuggestedParams, +) +from tinyman.swap_router.constants import ( + CLAIM_EXTRA_APP_ARGUMENT, + SET_MANAGER_APP_ARGUMENT, + SET_EXTRA_COLLECTOR_APP_ARGUMENT, +) +from tinyman.utils import TransactionGroup + + +def prepare_claim_extra_transactions( + router_app_id: int, + asset_ids: [int], + sender: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + claim_extra_app_call = ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=router_app_id, + app_args=[CLAIM_EXTRA_APP_ARGUMENT], + foreign_assets=asset_ids, + note=app_call_note, + ) + min_fee = suggested_params.min_fee + inner_transaction_count = len(asset_ids) + claim_extra_app_call.fee = min_fee * (1 + inner_transaction_count) + + txn_group = TransactionGroup([claim_extra_app_call]) + return txn_group + + +def prepare_set_set_manager_transactions( + router_app_id: int, + manager: str, + new_manager: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + txns = [ + ApplicationNoOpTxn( + sender=manager, + sp=suggested_params, + index=router_app_id, + app_args=[SET_MANAGER_APP_ARGUMENT], + accounts=[new_manager], + note=app_call_note, + ), + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_set_extra_collector_transactions( + router_app_id: int, + manager: str, + new_extra_collector: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + txns = [ + ApplicationNoOpTxn( + sender=manager, + sp=suggested_params, + index=router_app_id, + app_args=[SET_EXTRA_COLLECTOR_APP_ARGUMENT], + accounts=[new_extra_collector], + note=app_call_note, + ), + ] + txn_group = TransactionGroup(txns) + return txn_group diff --git a/tinyman/swap_router/routes.py b/tinyman/swap_router/routes.py new file mode 100644 index 0000000..1e58fdf --- /dev/null +++ b/tinyman/swap_router/routes.py @@ -0,0 +1,263 @@ +import math +from dataclasses import dataclass +from typing import Optional +from typing import Union + +from algosdk.constants import MIN_TXN_FEE + +from tinyman.assets import Asset, AssetAmount +from tinyman.compat import SuggestedParams +from tinyman.exceptions import ( + PoolHasNoLiquidity, + InsufficientReserves, + LowSwapAmountError, +) +from tinyman.utils import TransactionGroup +from tinyman.swap_router.constants import FIXED_INPUT_SWAP_TYPE, FIXED_OUTPUT_SWAP_TYPE +from tinyman.v1.pools import Pool as TinymanV1Pool +from tinyman.v2.pools import Pool as TinymanV2Pool +from tinyman.v2.quotes import SwapQuote as TinymanV2SwapQuote + + +@dataclass +class Route: + asset_in: Asset + asset_out: Asset + pools: "Union[list[TinymanV2Pool], list[TinymanV1Pool]]" + + def __str__(self): + return "Route: " + " -> ".join(f"{pool}" for pool in self.pools) + + def get_fixed_input_quotes(self, amount_in: int, slippage: float = 0.05): + quotes = [] + assert self.pools + + current_asset_in_amount = AssetAmount(asset=self.asset_in, amount=amount_in) + + for pool in self.pools: + quote = pool.fetch_fixed_input_swap_quote( + amount_in=current_asset_in_amount, + slippage=slippage, + refresh=False, + ) + + quotes.append(quote) + current_asset_in_amount = quote.amount_out + + assert quotes[-1].amount_out.asset.id == self.asset_out.id + return quotes + + def get_fixed_output_quotes(self, amount_out: int, slippage: float = 0.05): + quotes = [] + assert self.pools + + current_asset_out_amount = AssetAmount(asset=self.asset_out, amount=amount_out) + + for pool in self.pools[::-1]: + quote = pool.fetch_fixed_output_swap_quote( + amount_out=current_asset_out_amount, + slippage=slippage, + refresh=False, + ) + + quotes.append(quote) + current_asset_out_amount = quote.amount_in + + quotes.reverse() + assert quotes[0].amount_in.asset.id == self.asset_in.id + return quotes + + def prepare_swap_router_transactions_from_quotes( + self, + quotes: "list[TinymanV2SwapQuote]", + user_address: Optional[str] = None, + suggested_params: Optional[SuggestedParams] = None, + ) -> TransactionGroup: + from tinyman.swap_router.swap_router import prepare_swap_router_transactions + + quote_count = len(quotes) + if quote_count == 2: + pools = self.pools + swap_type = quotes[0].swap_type + tinyman_client = pools[0].client + user_address = user_address or tinyman_client.user_address + + router_app_id = tinyman_client.router_app_id + validator_app_id = tinyman_client.validator_app_id + + input_asset_id = quotes[0].amount_in.asset.id + intermediary_asset_id = quotes[0].amount_out.asset.id + output_asset_id = quotes[-1].amount_out.asset.id + + asset_in_amount = quotes[0].amount_in_with_slippage.amount + asset_out_amount = quotes[-1].amount_out_with_slippage.amount + + txn_group = prepare_swap_router_transactions( + router_app_id=router_app_id, + validator_app_id=validator_app_id, + input_asset_id=input_asset_id, + intermediary_asset_id=intermediary_asset_id, + output_asset_id=output_asset_id, + asset_in_amount=asset_in_amount, + asset_out_amount=asset_out_amount, + swap_type=swap_type, + user_address=user_address, + suggested_params=suggested_params, + app_call_note=tinyman_client.generate_app_call_note(), + ) + return txn_group + + elif quote_count == 1: + raise NotImplementedError( + "Use prepare_swap_transactions function of the pool directly." + ) + else: + raise NotImplementedError() + + @property + def asset_ids(self) -> "list[int]": + asset_ids = [self.asset_in.id] + + for pool in self.pools: + if isinstance(pool, TinymanV2Pool): + asset_1_id = pool.asset_1.id + asset_2_id = pool.asset_2.id + elif isinstance(pool, TinymanV1Pool): + asset_1_id = pool.asset1.id + asset_2_id = pool.asset2.id + else: + raise NotImplementedError() + + if asset_ids[-1] == asset_1_id: + asset_ids.append(asset_2_id) + else: + asset_ids.append(asset_1_id) + + return asset_ids + + @property + def price(self): + input_asset_id = self.asset_in.id + + pool_prices = [] + for pool in self.pools: + if isinstance(pool, TinymanV2Pool): + asset_1_id = pool.asset_1.id + asset_2_id = pool.asset_2.id + pool_asset_1_price = pool.asset_1_price + pool_asset_2_price = pool.asset_2_price + + elif isinstance(pool, TinymanV1Pool): + asset_1_id = pool.asset1.id + asset_2_id = pool.asset2.id + pool_asset_1_price = pool.asset1_price + pool_asset_2_price = pool.asset2_price + + else: + raise NotImplementedError() + + if input_asset_id == asset_1_id: + pool_prices.append(pool_asset_1_price) + input_asset_id = asset_2_id + else: + pool_prices.append(pool_asset_2_price) + input_asset_id = asset_1_id + + return math.prod(pool_prices) + + @classmethod + def get_swap_price_from_quotes( + cls, quotes, asset_in_algo_price: Optional[int] = None + ): + amount_in = quotes[0].amount_in.amount + amount_out = quotes[-1].amount_out.amount + + if asset_in_algo_price and asset_in_algo_price > 0: + transaction_count = cls.get_transaction_count(quotes) + + txn_fee_in_algo = MIN_TXN_FEE * transaction_count + txn_fee_in_asset_in = txn_fee_in_algo / asset_in_algo_price + amount_in += txn_fee_in_asset_in + + swap_price = amount_out / amount_in + return swap_price + + def get_price_impact_from_quotes(self, quotes): + swap_price = self.get_swap_price_from_quotes(quotes) + route_price = self.price + price_impact = round(1 - (swap_price / route_price), 5) + return price_impact + + @classmethod + def get_transaction_count(cls, quotes) -> int: + transaction_count_mapping = { + # Single Swap + 1: { + FIXED_INPUT_SWAP_TYPE: 3, + FIXED_OUTPUT_SWAP_TYPE: 4, + }, + # Swap Router (1 hop) + 2: { + FIXED_INPUT_SWAP_TYPE: 8, + FIXED_OUTPUT_SWAP_TYPE: 9, + }, + } + + swap_count = len(quotes) + swap_type = quotes[0].swap_type + transaction_count = transaction_count_mapping[swap_count][swap_type] + return transaction_count + + +def get_best_fixed_input_route( + routes: "list[Route]", amount_in: int, asset_in_algo_price: Optional[float] = None +) -> Optional[Route]: + best_route = None + best_route_price_impact = None + best_route_swap_price = None + + for route in routes: + try: + quotes = route.get_fixed_input_quotes(amount_in=amount_in) + except (InsufficientReserves, LowSwapAmountError, PoolHasNoLiquidity): + continue + + swap_price = route.get_swap_price_from_quotes(quotes, asset_in_algo_price) + price_impact = route.get_price_impact_from_quotes(quotes) + + if (not best_route) or ( + (best_route_swap_price, -best_route_price_impact) + < (swap_price, -price_impact) + ): + best_route = route + best_route_swap_price = swap_price + best_route_price_impact = price_impact + + return best_route + + +def get_best_fixed_output_route( + routes: "list[Route]", amount_out: int, asset_in_algo_price: Optional[float] = None +): + best_route = None + best_route_price_impact = None + best_route_swap_price = None + + for route in routes: + try: + quotes = route.get_fixed_output_quotes(amount_out=amount_out) + except (InsufficientReserves, LowSwapAmountError, PoolHasNoLiquidity): + continue + + swap_price = route.get_swap_price_from_quotes(quotes, asset_in_algo_price) + price_impact = route.get_price_impact_from_quotes(quotes) + + if (not best_route) or ( + (best_route_swap_price, -best_route_price_impact) + < (swap_price, -price_impact) + ): + best_route = route + best_route_swap_price = swap_price + best_route_price_impact = price_impact + + return best_route diff --git a/tinyman/swap_router/swap_router.py b/tinyman/swap_router/swap_router.py new file mode 100644 index 0000000..1ed9d3c --- /dev/null +++ b/tinyman/swap_router/swap_router.py @@ -0,0 +1,179 @@ +from typing import Optional + +import requests +from algosdk.logic import get_application_address +from algosdk.v2client.algod import AlgodClient + +from tinyman.assets import Asset +from tinyman.compat import ( + AssetTransferTxn, + ApplicationNoOpTxn, + SuggestedParams, + PaymentTxn, +) +from tinyman.swap_router.constants import ( + FIXED_INPUT_SWAP_TYPE, + FIXED_OUTPUT_SWAP_TYPE, + SWAP_APP_ARGUMENT, + ASSET_OPT_IN_APP_ARGUMENT, +) +from tinyman.swap_router.routes import Route +from tinyman.utils import TransactionGroup +from tinyman.v2.client import TinymanV2Client +from tinyman.v2.constants import FIXED_INPUT_APP_ARGUMENT, FIXED_OUTPUT_APP_ARGUMENT +from tinyman.v2.contracts import get_pool_logicsig +from tinyman.v2.pools import Pool as TinymanV2Pool + + +def prepare_swap_router_asset_opt_in_transaction( + router_app_id: int, + asset_ids: [int], + user_address: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + asset_opt_in_app_call = ApplicationNoOpTxn( + sender=user_address, + sp=suggested_params, + index=router_app_id, + app_args=[ASSET_OPT_IN_APP_ARGUMENT], + foreign_assets=asset_ids, + ) + min_fee = suggested_params.min_fee + inner_transaction_count = len(asset_ids) + asset_opt_in_app_call.fee = min_fee * (1 + inner_transaction_count) + + txn_group = TransactionGroup([asset_opt_in_app_call]) + return txn_group + + +def prepare_swap_router_transactions( + router_app_id: int, + validator_app_id: int, + input_asset_id: int, + intermediary_asset_id: int, + output_asset_id: int, + asset_in_amount: int, + asset_out_amount: int, + swap_type: [str, bytes], + user_address: str, + suggested_params: SuggestedParams, + app_call_note: Optional[str] = None, +) -> TransactionGroup: + pool_1_logicsig = get_pool_logicsig( + validator_app_id, input_asset_id, intermediary_asset_id + ) + pool_1_address = pool_1_logicsig.address() + + pool_2_logicsig = get_pool_logicsig( + validator_app_id, intermediary_asset_id, output_asset_id + ) + pool_2_address = pool_2_logicsig.address() + + txns = [ + AssetTransferTxn( + sender=user_address, + sp=suggested_params, + receiver=get_application_address(router_app_id), + index=input_asset_id, + amt=asset_in_amount, + ) + if input_asset_id != 0 + else PaymentTxn( + sender=user_address, + sp=suggested_params, + receiver=get_application_address(router_app_id), + amt=asset_in_amount, + ), + ApplicationNoOpTxn( + sender=user_address, + sp=suggested_params, + index=router_app_id, + app_args=[SWAP_APP_ARGUMENT, swap_type, asset_out_amount], + accounts=[pool_1_address, pool_2_address], + foreign_apps=[validator_app_id], + foreign_assets=[input_asset_id, intermediary_asset_id, output_asset_id], + note=app_call_note, + ), + ] + + if isinstance(swap_type, bytes): + pass + elif isinstance(swap_type, str): + swap_type = swap_type.encode() + else: + raise NotImplementedError() + + min_fee = suggested_params.min_fee + if swap_type == FIXED_INPUT_APP_ARGUMENT: + inner_transaction_count = 7 + app_call_fee = min_fee * (1 + inner_transaction_count) + elif swap_type == FIXED_OUTPUT_APP_ARGUMENT: + inner_transaction_count = 8 + app_call_fee = min_fee * (1 + inner_transaction_count) + else: + raise NotImplementedError() + + txns[-1].fee = app_call_fee + txn_group = TransactionGroup(txns) + return txn_group + + +def get_swap_router_app_opt_in_required_asset_ids( + algod_client: AlgodClient, router_app_id: int, asset_ids: "list[int]" +) -> "list[int]": + swap_router_app_address = get_application_address(router_app_id) + account_info = algod_client.account_info(swap_router_app_address) + + app_opted_in_asset_ids = { + int(asset["asset-id"]) for asset in account_info["assets"] + } + + app_opt_in_required_asset_ids = list(set(asset_ids) - {0} - app_opted_in_asset_ids) + return app_opt_in_required_asset_ids + + +def fetch_best_route_suggestion( + tinyman_client: TinymanV2Client, + asset_in: Asset, + asset_out: Asset, + swap_type: str, + amount: int, +) -> Route: + assert swap_type in (FIXED_INPUT_SWAP_TYPE, FIXED_OUTPUT_SWAP_TYPE) + assert amount > 0 + + payload = { + "asset_in_id": str(asset_in.id), + "asset_out_id": str(asset_out.id), + "swap_type": swap_type, + "amount": str(amount), + } + + r = requests.post( + tinyman_client.api_base_url + "v1/swap-router/quotes/", json=payload + ) + r.raise_for_status() + response = r.json() + + pools = [] + for quote in response["route"]: + pool = TinymanV2Pool( + client=tinyman_client, + asset_a=Asset( + id=int(quote["pool"]["asset_1"]["id"]), + name=quote["pool"]["asset_1"]["name"], + unit_name=quote["pool"]["asset_1"]["unit_name"], + decimals=quote["pool"]["asset_1"]["decimals"], + ), + asset_b=Asset( + id=int(quote["pool"]["asset_2"]["id"]), + name=quote["pool"]["asset_2"]["name"], + unit_name=quote["pool"]["asset_2"]["unit_name"], + decimals=quote["pool"]["asset_2"]["decimals"], + ), + fetch=True, + ) + pools.append(pool) + + route = Route(asset_in=asset_in, asset_out=asset_out, pools=pools) + return route diff --git a/tinyman/swap_router/utils.py b/tinyman/swap_router/utils.py new file mode 100644 index 0000000..a2e321e --- /dev/null +++ b/tinyman/swap_router/utils.py @@ -0,0 +1,21 @@ +from base64 import b64decode +from typing import Union, Optional + + +def parse_swap_router_event_log(log: Union[bytes, str]) -> Optional[dict]: + # Signature is "swap(uint64,uint64,uint64,uint64)" + swap_event_selector = b"\x81b\xda\x9e" + + if isinstance(log, str): + # Indexer returns logs as b64 encoded. + log = b64decode(log) + + if log[:4] == swap_event_selector and len(log) >= 36: + return dict( + input_asset_id=int.from_bytes(log[4:12], "big"), + output_asset_id=int.from_bytes(log[12:20], "big"), + input_amount=int.from_bytes(log[20:28], "big"), + output_amount=int.from_bytes(log[28:36], "big"), + ) + + return None diff --git a/tinyman/utils.py b/tinyman/utils.py index 5e43659..1296966 100644 --- a/tinyman/utils.py +++ b/tinyman/utils.py @@ -114,7 +114,7 @@ def calculate_price_impact( ): swap_price = swap_output_amount / swap_input_amount pool_price = output_supply / input_supply - price_impact = abs(round((swap_price / pool_price) - 1, 5)) + price_impact = round(1 - (swap_price / pool_price), 5) return price_impact diff --git a/tinyman/v1/client.py b/tinyman/v1/client.py index 49d9b7f..d873dbb 100644 --- a/tinyman/v1/client.py +++ b/tinyman/v1/client.py @@ -1,16 +1,16 @@ from base64 import b64decode from typing import Optional -from algosdk.v2client.algod import AlgodClient from algosdk.encoding import encode_address +from algosdk.v2client.algod import AlgodClient + from tinyman.assets import AssetAmount from tinyman.client import BaseTinymanClient +from tinyman.optin import prepare_app_optin_transactions from tinyman.staking.constants import ( TESTNET_STAKING_APP_ID, MAINNET_STAKING_APP_ID, ) - -from tinyman.optin import prepare_app_optin_transactions from tinyman.v1.constants import ( TESTNET_VALIDATOR_APP_ID, MAINNET_VALIDATOR_APP_ID, @@ -76,6 +76,7 @@ def __init__( super().__init__( algod_client, validator_app_id=TESTNET_VALIDATOR_APP_ID, + api_base_url="https://testnet.analytics.tinyman.org/api/", user_address=user_address, staking_app_id=TESTNET_STAKING_APP_ID, client_name=client_name, @@ -92,6 +93,7 @@ def __init__( super().__init__( algod_client, validator_app_id=MAINNET_VALIDATOR_APP_ID, + api_base_url="https://mainnet.analytics.tinyman.org/api/", user_address=user_address, staking_app_id=MAINNET_STAKING_APP_ID, client_name=client_name, diff --git a/tinyman/v1/pools.py b/tinyman/v1/pools.py index 8d35837..c6a90c9 100644 --- a/tinyman/v1/pools.py +++ b/tinyman/v1/pools.py @@ -1,19 +1,22 @@ import math -from dataclasses import dataclass from base64 import b64encode -from algosdk.v2client.algod import AlgodClient +from dataclasses import dataclass + from algosdk.encoding import decode_address -from .contracts import get_pool_logicsig -from tinyman.utils import get_state_int, calculate_price_impact +from algosdk.v2client.algod import AlgodClient + from tinyman.assets import Asset, AssetAmount -from .swap import prepare_swap_transactions -from .bootstrap import prepare_bootstrap_transactions -from .mint import prepare_mint_transactions -from .burn import prepare_burn_transactions -from .redeem import prepare_redeem_transactions +from tinyman.exceptions import InsufficientReserves, PoolHasNoLiquidity from tinyman.optin import prepare_asset_optin_transactions -from .fees import prepare_redeem_fees_transactions -from .client import TinymanClient +from tinyman.utils import get_state_int, calculate_price_impact +from tinyman.v1.bootstrap import prepare_bootstrap_transactions +from tinyman.v1.burn import prepare_burn_transactions +from tinyman.v1.client import TinymanClient +from tinyman.v1.fees import prepare_redeem_fees_transactions +from tinyman.v1.mint import prepare_mint_transactions +from tinyman.v1.redeem import prepare_redeem_transactions +from tinyman.v1.swap import prepare_swap_transactions +from .contracts import get_pool_logicsig def get_pool_info(client: AlgodClient, validator_app_id, asset1_id, asset2_id): @@ -189,6 +192,9 @@ def __init__( elif info is not None: self.update_from_info(info) + def __repr__(self): + return f"Pool V1 {self.asset1.unit_name}({self.asset1.id})-{self.asset2.unit_name}({self.asset2.id}) {self.address}" + @classmethod def from_account_info(cls, account_info, client=None): info = get_pool_info_from_account_info(account_info) @@ -365,7 +371,7 @@ def fetch_fixed_input_swap_quote( output_supply = self.asset1_reserves if not input_supply or not output_supply: - raise Exception("Pool has no liquidity!") + raise PoolHasNoLiquidity() # k = input_supply * output_supply # ignoring fees, k must remain constant @@ -411,6 +417,9 @@ def fetch_fixed_output_swap_quote( input_supply = self.asset1_reserves output_supply = self.asset2_reserves + if output_supply <= amount_out.amount: + raise InsufficientReserves() + # k = input_supply * output_supply # ignoring fees, k must remain constant # (input_supply + asset_in) * (output_supply - amount_out) = k diff --git a/tinyman/v2/client.py b/tinyman/v2/client.py index 6576b91..247ff7f 100644 --- a/tinyman/v2/client.py +++ b/tinyman/v2/client.py @@ -1,23 +1,30 @@ from typing import Optional from algosdk.v2client.algod import AlgodClient + from tinyman.client import BaseTinymanClient +from tinyman.errors import LogicError from tinyman.staking.constants import ( TESTNET_STAKING_APP_ID, MAINNET_STAKING_APP_ID, ) - +from tinyman.swap_router.constants import ( + TESTNET_SWAP_ROUTER_APP_ID_V1, + MAINNET_SWAP_ROUTER_APP_ID_V1, +) +from tinyman.utils import find_app_id_from_txn_id, parse_error from tinyman.v2.constants import ( TESTNET_VALIDATOR_APP_ID, MAINNET_VALIDATOR_APP_ID, ) - -from tinyman.utils import find_app_id_from_txn_id, parse_error -from .utils import lookup_error -from tinyman.errors import LogicError +from tinyman.v2.utils import lookup_error, get_tealishmap class TinymanV2Client(BaseTinymanClient): + def __init__(self, *args, **kwargs): + self.router_app_id = kwargs.pop("router_app_id", None) + super().__init__(*args, **kwargs) + def fetch_pool(self, asset_a, asset_b, fetch=True): from .pools import Pool @@ -27,9 +34,11 @@ def handle_error(self, exception, txn_group): error = parse_error(exception) if isinstance(error, LogicError): app_id = find_app_id_from_txn_id(txn_group, error.txn_id) - if app_id in (TESTNET_VALIDATOR_APP_ID, MAINNET_VALIDATOR_APP_ID): + tealishmap = get_tealishmap(app_id) + if tealishmap: error.app_id = app_id - error.message = lookup_error(error.pc, error.message) + error.message = lookup_error(error.pc, error.message, tealishmap) + raise error from None @@ -39,10 +48,13 @@ def __init__( algod_client: AlgodClient, user_address: Optional[str] = None, client_name: Optional[str] = None, + api_base_url: Optional[str] = None, ): super().__init__( algod_client, validator_app_id=TESTNET_VALIDATOR_APP_ID, + router_app_id=TESTNET_SWAP_ROUTER_APP_ID_V1, + api_base_url=api_base_url or "https://testnet.analytics.tinyman.org/api/", user_address=user_address, staking_app_id=TESTNET_STAKING_APP_ID, client_name=client_name, @@ -55,10 +67,13 @@ def __init__( algod_client: AlgodClient, user_address: Optional[str] = None, client_name: Optional[str] = None, + api_base_url: Optional[str] = None, ): super().__init__( algod_client, validator_app_id=MAINNET_VALIDATOR_APP_ID, + router_app_id=MAINNET_SWAP_ROUTER_APP_ID_V1, + api_base_url=api_base_url or "https://mainnet.analytics.tinyman.org/api/", user_address=user_address, staking_app_id=MAINNET_STAKING_APP_ID, client_name=client_name, diff --git a/tinyman/v2/exceptions.py b/tinyman/v2/exceptions.py index 8825ab4..0ee2ad6 100644 --- a/tinyman/v2/exceptions.py +++ b/tinyman/v2/exceptions.py @@ -1,18 +1,9 @@ -class PoolIsNotBootstrapped(Exception): - pass - - -class PoolAlreadyBootstrapped(Exception): - pass - - -class PoolHasNoLiquidity(Exception): - pass - - -class PoolAlreadyInitialized(Exception): - pass - - -class InsufficientReserves(Exception): - pass +# flake8: noqa, for backward compatibility. + +from tinyman.exceptions import ( + PoolIsNotBootstrapped, + PoolAlreadyBootstrapped, + PoolHasNoLiquidity, + PoolAlreadyInitialized, + InsufficientReserves, +) diff --git a/tinyman/v2/formulas.py b/tinyman/v2/formulas.py index 258c9df..48530cd 100644 --- a/tinyman/v2/formulas.py +++ b/tinyman/v2/formulas.py @@ -2,7 +2,7 @@ from tinyman.utils import calculate_price_impact from tinyman.v2.constants import LOCKED_POOL_TOKENS -from tinyman.v2.exceptions import InsufficientReserves +from tinyman.exceptions import InsufficientReserves, LowSwapAmountError def calculate_protocol_fee_amount( @@ -240,6 +240,9 @@ def calculate_output_amount_of_fixed_input_swap( k = input_supply * output_supply output_amount = output_supply - int(k / (input_supply + swap_amount)) output_amount -= 1 + + # On-chain app raises an error if output_amount is less than zero. + output_amount = max(output_amount, 0) return output_amount @@ -257,6 +260,9 @@ def calculate_swap_amount_of_fixed_output_swap( def calculate_fixed_input_swap( input_supply: int, output_supply: int, swap_input_amount: int, total_fee_share: int ) -> (int, int, int, float): + if not swap_input_amount: + raise LowSwapAmountError() + total_fee_amount = calculate_fixed_input_fee_amount( input_amount=swap_input_amount, total_fee_share=total_fee_share ) @@ -265,9 +271,6 @@ def calculate_fixed_input_swap( input_supply, output_supply, swap_amount ) - if swap_output_amount <= 0: - raise InsufficientReserves() - price_impact = calculate_price_impact( input_supply=input_supply, output_supply=output_supply, diff --git a/tinyman/v2/pools.py b/tinyman/v2/pools.py index 607d8ec..0175c10 100644 --- a/tinyman/v2/pools.py +++ b/tinyman/v2/pools.py @@ -1,9 +1,10 @@ from typing import Optional -from tinyman.compat import LogicSigAccount, Transaction, SuggestedParams from algosdk.v2client.algod import AlgodClient from tinyman.assets import Asset, AssetAmount +from tinyman.compat import LogicSigAccount, Transaction, SuggestedParams +from tinyman.exceptions import LowSwapAmountError from tinyman.optin import prepare_asset_optin_transactions from tinyman.utils import TransactionGroup from .add_liquidity import ( @@ -143,7 +144,7 @@ def __init__( self.update_from_info(info, fetch) def __repr__(self): - return f"Pool {self.asset_1.unit_name}({self.asset_1.id})-{self.asset_2.unit_name}({self.asset_2.id}) {self.address}" + return f"Pool V2 {self.asset_1.unit_name}({self.asset_1.id})-{self.asset_2.unit_name}({self.asset_2.id}) {self.address}" @classmethod def from_account_info( @@ -331,7 +332,6 @@ def prepare_bootstrap_transactions( refresh: bool = True, suggested_params: SuggestedParams = None, ) -> TransactionGroup: - user_address = user_address or self.client.user_address if refresh: @@ -913,6 +913,9 @@ def fetch_fixed_input_swap_quote( ) amount_out = AssetAmount(asset_out, swap_output_amount) + if not total_fee_amount: + raise LowSwapAmountError() + quote = SwapQuote( swap_type="fixed-input", amount_in=amount_in, @@ -954,6 +957,9 @@ def fetch_fixed_output_swap_quote( ) amount_in = AssetAmount(asset_in, swap_input_amount) + if not total_fee_amount: + raise LowSwapAmountError() + quote = SwapQuote( swap_type="fixed-output", amount_out=amount_out, diff --git a/tinyman/v2/swap_router_approval.map.json b/tinyman/v2/swap_router_approval.map.json new file mode 100644 index 0000000..0224519 --- /dev/null +++ b/tinyman/v2/swap_router_approval.map.json @@ -0,0 +1,20 @@ +{ + "pc_teal": [0, 0, 0, 0, 0, 0, 0, 0, 4, 4, 5, 6, 6, 6, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 11, 11, 11, 12, 14, 14, 14, 14, 14, 14, 14, 14, 14, 15, 15, 16, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 19, 19, 20, 22, 22, 23, 27, 27, 28, 28, 29, 30, 30, 30, 31, 31, 32, 32, 33, 34, 34, 34, 35, 35, 36, 36, 37, 38, 38, 38, 39, 39, 40, 40, 41, 42, 42, 42, 43, 43, 44, 44, 45, 46, 46, 46, 47, 52, 52, 53, 58, 58, 58, 59, 59, 59, 59, 59, 59, 60, 61, 61, 61, 62, 62, 62, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 64, 65, 65, 65, 66, 66, 66, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 68, 69, 69, 69, 70, 70, 70, 71, 71, 71, 71, 71, 71, 71, 71, 71, 71, 71, 71, 71, 71, 71, 71, 71, 71, 71, 71, 71, 72, 73, 73, 73, 74, 74, 74, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 76, 77, 77, 77, 78, 86, 86, 87, 87, 89, 89, 90, 90, 92, 92, 93, 93, 94, 95, 95, 95, 97, 97, 98, 98, 99, 99, 99, 100, 100, 101, 101, 102, 103, 103, 104, 104, 104, 107, 107, 108, 114, 114, 114, 114, 114, 114, 114, 114, 114, 114, 114, 114, 114, 114, 114, 114, 115, 116, 116, 116, 117, 118, 121, 121, 122, 122, 124, 124, 124, 125, 125, 127, 127, 127, 128, 128, 132, 132, 132, 133, 133, 135, 135, 135, 136, 136, 138, 138, 138, 139, 139, 144, 144, 145, 145, 145, 148, 148, 149, 149, 150, 150, 151, 151, 152, 154, 154, 155, 158, 158, 159, 159, 159, 162, 162, 163, 163, 164, 164, 165, 165, 166, 168, 168, 169, 175, 175, 176, 178, 178, 179, 179, 180, 181, 181, 183, 183, 184, 184, 185, 185, 186, 187, 190, 190, 191, 191, 192, 192, 193, 194, 194, 194, 197, 197, 198, 198, 199, 199, 200, 201, 203, 203, 204, 205, 207, 207, 208, 208, 209, 209, 210, 210, 210, 213, 213, 214, 214, 215, 215, 216, 217, 217, 217, 219, 219, 220, 220, 221, 221, 222, 223, 225, 225, 226, 226, 227, 227, 228, 229, 231, 231, 232, 232, 233, 233, 234, 234, 234, 238, 241, 241, 242, 246, 246, 246, 247, 247, 247, 247, 247, 247, 247, 247, 247, 247, 247, 247, 247, 248, 249, 249, 249, 250, 250, 250, 251, 251, 251, 251, 251, 251, 251, 251, 251, 251, 251, 251, 251, 251, 252, 253, 253, 253, 254, 259, 259, 259, 260, 261, 261, 263, 263, 264, 264, 270, 270, 271, 271, 271, 271, 271, 271, 271, 271, 271, 271, 271, 271, 271, 272, 272, 273, 273, 274, 274, 275, 275, 276, 276, 276, 277, 277, 278, 280, 280, 281, 286, 286, 287, 287, 287, 287, 287, 287, 287, 287, 287, 287, 287, 287, 287, 288, 288, 289, 289, 290, 290, 291, 291, 292, 292, 292, 293, 293, 294, 296, 296, 297, 297, 298, 299, 303, 303, 304, 304, 305, 305, 306, 306, 307, 307, 307, 310, 311, 311, 312, 313, 314, 314, 315, 316, 317, 317, 318, 319, 320, 320, 321, 322, 323, 325, 325, 326, 331, 331, 331, 332, 333, 333, 337, 337, 338, 338, 339, 339, 339, 340, 340, 342, 342, 343, 343, 344, 344, 344, 345, 345, 347, 347, 348, 348, 349, 349, 349, 350, 350, 352, 352, 353, 353, 354, 354, 354, 355, 355, 364, 364, 365, 365, 366, 366, 367, 367, 367, 368, 368, 370, 370, 371, 371, 372, 372, 372, 373, 373, 375, 375, 376, 376, 377, 378, 378, 382, 382, 383, 383, 384, 384, 385, 385, 385, 386, 386, 388, 388, 389, 389, 390, 390, 390, 391, 391, 393, 393, 394, 394, 395, 396, 396, 402, 402, 403, 403, 403, 403, 403, 403, 403, 403, 403, 403, 403, 403, 403, 403, 404, 404, 405, 405, 406, 406, 407, 407, 408, 408, 408, 409, 409, 410, 410, 412, 412, 413, 413, 414, 415, 417, 417, 418, 419, 425, 425, 426, 426, 426, 426, 426, 426, 426, 426, 426, 426, 426, 426, 426, 426, 427, 427, 428, 428, 429, 429, 430, 430, 431, 431, 431, 432, 432, 433, 433, 435, 435, 436, 436, 437, 438, 440, 440, 441, 442, 446, 446, 447, 447, 448, 449, 449, 451, 451, 452, 452, 452, 455, 455, 456, 456, 457, 457, 458, 458, 459, 459, 459, 464, 464, 465, 465, 466, 466, 467, 467, 468, 468, 468, 471, 472, 472, 473, 474, 475, 475, 476, 477, 478, 478, 479, 479, 480, 481, 482, 483, 483, 484, 485, 486, 488, 488, 489, 491, 491, 492, 500, 500, 501, 501, 501, 501, 501, 501, 501, 501, 501, 502, 503, 504, 508, 508, 508, 508, 508, 508, 508, 508, 508, 509, 509, 509, 510, 513, 513, 514, 522, 522, 523, 523, 523, 523, 523, 523, 523, 523, 523, 524, 525, 526, 530, 530, 530, 530, 530, 530, 530, 530, 530, 530, 530, 530, 530, 530, 530, 530, 530, 531, 531, 531, 532, 535, 535, 536, 544, 544, 545, 545, 546, 547, 552, 552, 553, 553, 556, 556, 557, 557, 559, 559, 560, 560, 561, 562, 562, 562, 564, 564, 565, 565, 566, 566, 568, 568, 569, 569, 570, 570, 570, 571, 571, 573, 573, 574, 574, 574, 577, 577, 578, 578, 579, 579, 580, 580, 580, 580, 580, 580, 580, 580, 580, 580, 580, 580, 580, 580, 580, 580, 580, 581, 582, 582, 582, 584, 584, 585, 585, 586, 587, 587, 588, 588, 588, 591, 591, 592, 594, 594, 595, 599, 599, 600, 600, 601, 601, 602, 602, 603, 603, 604, 604, 606, 606, 607, 607, 608, 608, 608, 609, 609, 611, 611, 612, 612, 613, 613, 613, 614, 614, 617, 617, 618, 618, 618, 621, 624, 624, 625, 625, 627, 627, 628, 628, 630, 630, 631, 631, 633, 633, 634, 634, 636, 636, 637, 637, 640, 642, 642, 643, 643, 645, 645, 646, 646, 648, 648, 648, 648, 648, 648, 648, 648, 648, 648, 648, 648, 648, 648, 648, 648, 649, 650, 650, 652, 652, 652, 652, 652, 652, 653, 653, 655, 655, 656, 656, 658, 658, 659, 660, 660, 662, 662, 663, 663, 665, 665, 666, 666, 668, 668, 669, 669, 671, 671, 672, 672, 674, 676, 676, 676, 680, 683, 683, 684, 684, 686, 686, 687, 687, 689, 689, 690, 690, 692, 692, 693, 693, 696, 698, 698, 699, 699, 701, 701, 702, 702, 704, 704, 704, 704, 704, 704, 704, 704, 704, 704, 704, 704, 704, 704, 704, 704, 705, 706, 706, 708, 708, 708, 708, 708, 708, 709, 709, 711, 711, 712, 712, 714, 714, 715, 716, 716, 718, 718, 719, 719, 721, 721, 722, 722, 724, 724, 725, 725, 727, 727, 728, 728, 730, 735, 735, 736, 736, 737, 737, 737, 738, 738, 740, 740, 741, 741, 742, 742, 742, 743, 743, 745, 745, 746, 746, 747, 748, 748, 750, 750, 751, 751, 752, 752, 753, 754, 755, 755, 757, 757, 758, 758, 759, 763, 763, 764, 764, 770, 770, 770, 770, 770, 770, 770, 770, 770, 770, 770, 770, 770, 770, 770, 770, 771, 772, 772, 775, 775, 776, 776, 777, 777, 777, 777, 777, 777, 777, 777, 777, 777, 777, 777, 778, 779, 779, 780, 780, 782, 782, 783, 785, 785, 786, 786, 787, 788, 788, 788, 791, 791, 792, 792, 793, 793, 793, 793, 793, 793, 793, 793, 793, 793, 793, 793, 793, 793, 793, 793, 793, 793, 794, 795, 796, 796, 798, 798, 799, 803, 803, 804, 804, 805, 805, 805, 805, 805, 805, 805, 805, 805, 805, 805, 805, 806, 807, 808, 808, 810, 810, 811, 811, 812, 813, 813, 813, 816, 816, 817, 817, 818, 818, 818, 818, 818, 818, 818, 818, 818, 818, 818, 818, 818, 818, 818, 818, 818, 818, 819, 820, 821, 821, 823, 823, 824, 828, 830, 834, 834, 836, 836, 837, 837, 837, 841, 841, 842, 842, 843, 843, 844, 844, 845, 848, 848, 849, 849, 850, 851, 851, 851, 854, 854, 855, 855, 856, 856, 857, 857, 858, 858, 858, 862, 866, 866, 867, 867, 873, 873, 874, 874, 876, 876, 877, 878, 878, 878, 881, 881, 882, 883, 883, 884, 885, 886, 886, 887, 887, 887, 891, 891, 892, 892, 893, 893, 894, 895, 895, 898, 898, 899, 903, 903, 904, 904, 905, 905, 913, 913, 914, 915, 915, 916, 917, 918, 918, 921, 921, 922, 922, 923, 923, 924, 925, 926, 927, 928, 928, 929, 930, 930, 931, 932, 932, 934, 934, 935, 939, 939, 940, 940, 944, 944, 944, 944, 944, 944, 944, 944, 944, 944, 944, 944, 944, 944, 944, 944, 945, 946, 946, 949, 949, 950, 950, 951, 951, 951, 951, 951, 951, 951, 951, 951, 951, 951, 951, 951, 951, 951, 951, 951, 952, 953, 953, 954, 954, 956, 956, 957, 960, 960, 961, 961, 961, 962, 963, 963, 963, 964, 964, 965, 966, 967, 967, 969, 969, 970, 970, 971, 972, 972, 974, 974, 975, 979, 979, 980, 980, 981, 981, 982, 982, 988, 988, 989, 990, 990, 990, 993, 995, 995, 996, 996, 998, 998, 999, 999, 1001, 1001, 1002, 1002, 1004, 1004, 1005, 1005, 1007, 1007, 1008, 1008, 1009, 1011, 1011, 1011, 1015, 1017, 1017, 1018, 1018, 1020, 1020, 1021, 1021, 1023, 1023, 1024, 1024, 1026, 1026, 1027, 1027, 1029, 1029, 1030, 1030, 1032, 1032, 1033, 1033, 1034, 1038], + "teal_tealish": [0, 1, 2, 3, 4, 4, 4, 4, 4, 5, 6, 6, 6, 6, 7, 7, 7, 7, 8, 8, 8, 8, 9, 9, 9, 4, 11, 12, 12, 13, 12, 12, 12, 14, 12, 12, 12, 15, 12, 12, 12, 16, 12, 12, 12, 17, 12, 12, 12, 19, 20, 20, 21, 21, 21, 23, 24, 24, 25, 25, 26, 25, 25, 25, 27, 25, 25, 25, 28, 25, 25, 25, 29, 25, 25, 25, 30, 25, 25, 25, 32, 33, 33, 34, 35, 36, 37, 37, 37, 38, 38, 38, 38, 38, 38, 38, 38, 39, 39, 39, 39, 38, 38, 38, 38, 38, 38, 41, 41, 41, 43, 44, 44, 45, 46, 46, 46, 46, 46, 46, 47, 48, 48, 48, 49, 49, 49, 50, 50, 50, 51, 52, 53, 53, 53, 54, 54, 54, 55, 55, 55, 56, 57, 58, 59, 59, 59, 59, 60, 60, 60, 60, 60, 60, 61, 61, 61, 59, 63, 63, 63, 63, 64, 64, 64, 64, 64, 64, 65, 65, 65, 63, 67, 68, 69, 70, 70, 70, 71, 71, 71, 71, 71, 72, 72, 72, 72, 72, 72, 73, 74, 74, 74, 74, 74, 74, 74, 75, 75, 75, 75, 75, 75, 76, 76, 76, 76, 77, 77, 77, 77, 74, 74, 78, 78, 78, 78, 78, 78, 79, 79, 79, 79, 79, 79, 80, 80, 80, 80, 80, 80, 81, 81, 81, 81, 74, 74, 82, 83, 83, 74, 85, 85, 85, 86, 87, 88, 88, 89, 88, 88, 88, 90, 88, 88, 88, 92, 93, 93, 94, 94, 94, 94, 95, 95, 95, 96, 97, 98, 99, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 101, 101, 101, 102, 103, 104, 105, 105, 105, 105, 105, 105, 105, 105, 105, 105, 106, 106, 106, 106, 106, 107, 108, 109, 109, 109, 109, 109, 109, 110, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 112, 112, 112, 114, 115, 115, 116, 116, 116, 116, 117, 118, 119, 119, 119, 119, 119, 120, 120, 120, 120, 120, 121, 121, 121, 121, 121, 122, 122, 122, 122, 122, 123, 124, 125, 126, 127, 128, 129, 130, 130, 130, 130, 130, 130, 131, 131, 131, 131, 131, 132, 132, 132, 132, 132, 133, 134, 135, 135, 135, 135, 135, 135, 136, 136, 136, 136, 136, 137, 137, 137, 137, 137, 138, 139, 140, 141, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 143, 143, 143, 143, 143, 144, 144, 144, 144, 145, 146, 147, 148, 149, 149, 149, 149, 149, 149, 149, 149, 149, 149, 150, 150, 150, 150, 150, 151, 151, 151, 151, 152, 153, 154, 154, 154, 154, 154, 155, 155, 155, 155, 156, 156, 156, 156, 156, 156, 155, 158, 159, 160, 160, 160, 160, 160, 160, 161, 162, 162, 162, 162, 162, 162, 162, 162, 162, 162, 162, 162, 162, 162, 162, 162, 162, 163, 163, 163, 165, 165, 165, 167, 168, 168, 169, 170, 171, 172, 172, 172, 172, 172, 172, 173, 174, 175, 175, 175, 175, 176, 177, 177, 177, 179, 180, 180, 181, 182, 183, 184, 184, 184, 184, 184, 184, 185, 186, 187, 187, 187, 187, 188, 189, 189, 189, 191, 192, 192, 193, 194, 195, 196, 196, 196, 196, 196, 197, 198, 199, 200, 200, 200, 201, 202, 202, 202, 202, 202, 202, 202, 202, 203, 203, 203, 203, 204, 204, 204, 204, 204, 205, 205, 205, 205, 206, 206, 206, 206, 206, 206, 206, 205, 202, 202, 202, 202, 202, 202, 209, 209, 209, 211, 211, 211, 213, 214, 214, 214, 214, 214, 214, 214, 214, 215, 215, 215, 215, 215, 216, 216, 216, 216, 216, 217, 218, 218, 218, 218, 219, 219, 220, 221, 221, 221, 222, 222, 222, 223, 223, 223, 224, 224, 224, 225, 225, 225, 220, 227, 227, 228, 228, 228, 229, 229, 229, 230, 230, 230, 230, 231, 231, 231, 232, 232, 232, 233, 233, 233, 233, 234, 234, 234, 235, 235, 235, 236, 236, 236, 237, 237, 237, 227, 219, 219, 218, 218, 240, 241, 241, 242, 243, 243, 243, 244, 244, 244, 245, 245, 245, 246, 246, 246, 242, 248, 248, 249, 249, 249, 250, 250, 250, 251, 251, 251, 251, 252, 252, 252, 253, 253, 253, 254, 254, 254, 254, 255, 255, 255, 256, 256, 256, 257, 257, 257, 258, 258, 258, 248, 241, 241, 218, 262, 263, 263, 263, 263, 263, 264, 264, 264, 264, 264, 265, 265, 265, 265, 265, 266, 266, 266, 266, 266, 266, 266, 267, 214, 214, 267, 269, 270, 270, 270, 270, 271, 272, 273, 274, 275, 275, 275, 275, 276, 277, 277, 277, 277, 277, 277, 277, 278, 278, 278, 279, 279, 279, 279, 279, 279, 280, 280, 280, 280, 280, 280, 280, 281, 279, 281, 279, 283, 284, 284, 284, 284, 284, 284, 284, 285, 285, 285, 285, 285, 285, 286, 286, 286, 286, 286, 286, 286, 287, 285, 287, 285, 289, 290, 290, 291, 291, 293, 294, 294, 294, 295, 295, 295, 295, 296, 297, 297, 297, 297, 297, 297, 298, 299, 299, 299, 299, 299, 299, 300, 300, 300, 300, 300, 300, 299, 295, 303, 303, 305, 306, 306, 306, 306, 307, 308, 309, 310, 311, 311, 311, 312, 312, 312, 312, 312, 313, 313, 313, 313, 313, 313, 313, 312, 312, 314, 315, 315, 315, 315, 315, 315, 312, 317, 306, 317, 319, 320, 320, 320, 320, 320, 321, 322, 323, 324, 325, 326, 327, 327, 327, 327, 327, 327, 327, 328, 329, 329, 329, 329, 329, 329, 329, 329, 329, 329, 329, 329, 329, 330, 320, 330, 332, 333, 333, 333, 333, 334, 335, 336, 336, 336, 336, 337, 338, 338, 338, 338, 338, 338, 338, 339, 339, 339, 340, 341, 341, 341, 341, 341, 341, 341, 341, 341, 342, 342, 342, 342, 342, 343, 333, 343, 345, 346, 346, 346, 346, 346, 346, 347, 348, 349, 350, 351, 351, 351, 351, 351, 352, 352, 353, 353, 353, 354, 354, 354, 355, 355, 355, 356, 356, 356, 357, 357, 357, 352, 352, 351, 351, 359, 360, 360, 361, 361, 361, 362, 362, 362, 363, 363, 363, 364, 364, 364, 365, 365, 365, 366, 366, 366, 360, 360, 351, 369, 369], + "errors": { + "46": "Invalid transaction: The first foreign app reference must be Tinyman AMM.", + "61": "Swap Router must opt-in to intermediary asset. asset_opt_in method should be called.", + "65": "Swap Router must opt-in to output asset. asset_opt_in method should be called.", + "70": "Invalid transaction: Invalid transaction group structure.", + "72": "Invalid transaction: Sender of transactions must be the same.", + "75": "Invalid transaction: Receiver of transfer must be swap router account.", + "76": "Invalid transaction: The first foreign asset reference must be input asset.", + "79": "Invalid transaction: Receiver of transfer must be swap router account.", + "80": "Invalid transaction: The first foreign asset reference must be input asset.", + "85": "Input amount must be greater than 0.", + "278": "One of the pools has no liquidity.", + "329": "One of the pools has not enough liquidity.", + "339": "One of the pools has not bootstrapped.", + "360": "Asset transfer failed, please check opted in assets." + } +} \ No newline at end of file diff --git a/tinyman/v2/utils.py b/tinyman/v2/utils.py index fc3404a..a26600f 100644 --- a/tinyman/v2/utils.py +++ b/tinyman/v2/utils.py @@ -4,21 +4,40 @@ from base64 import b64decode import tinyman.v2 +from tinyman.swap_router.constants import ( + TESTNET_SWAP_ROUTER_APP_ID_V1, + MAINNET_SWAP_ROUTER_APP_ID_V1, +) from tinyman.tealishmap import TealishMap from tinyman.utils import bytes_to_int +from tinyman.v2.constants import TESTNET_VALIDATOR_APP_ID, MAINNET_VALIDATOR_APP_ID if sys.version_info >= (3, 9): - tealishmap = TealishMap( + amm_tealishmap = TealishMap( json.loads( importlib.resources.files(tinyman.v2) .joinpath("amm_approval.map.json") .read_text() ) ) + swap_router_tealishmap = TealishMap( + json.loads( + importlib.resources.files(tinyman.v2) + .joinpath("swap_router_approval.map.json") + .read_text() + ) + ) + + else: - tealishmap = TealishMap( + amm_tealishmap = TealishMap( json.loads(importlib.resources.read_text(tinyman.v2, "amm_approval.map.json")) ) + swap_router_tealishmap = TealishMap( + json.loads( + importlib.resources.read_text(tinyman.v2, "swap_router_approval.map.json") + ) + ) def decode_logs(logs: "list") -> dict: @@ -55,7 +74,17 @@ def get_state_from_account_info(account_info, app_id): return app_state -def lookup_error(pc, error_message): +def get_tealishmap(app_id): + maps = { + TESTNET_VALIDATOR_APP_ID: amm_tealishmap, + MAINNET_VALIDATOR_APP_ID: amm_tealishmap, + TESTNET_SWAP_ROUTER_APP_ID_V1: swap_router_tealishmap, + MAINNET_SWAP_ROUTER_APP_ID_V1: swap_router_tealishmap, + } + return maps.get(app_id) + + +def lookup_error(pc, error_message, tealishmap): tealish_line_no = tealishmap.get_tealish_line_for_pc(int(pc)) if "assert failed" in error_message or "err opcode executed" in error_message: custom_error_message = tealishmap.get_error_for_pc(int(pc))