diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..4fb53ea --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +ignore = E501,F403,F405,E126,E121,W503,E203 \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b1384da --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: Lint & Tests + +on: [push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.9", "3.8"] + py-algorand-sdk-version: ["1.18", "1.17", "1.16", "1.15", "1.14", "1.13"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 black py-algorand-sdk==${{ matrix.py-algorand-sdk-version }} + + - name: Run flake8 + run: flake8 ${{ github.workspace }} --ignore=E501,F403,F405,E126,E121,W503,E203 + + - name: Run Black + run: black ${{ github.workspace }} --check + + - name: Run Unit tests + run: python -m unittest diff --git a/.gitignore b/.gitignore index c821c84..eff2877 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.idea # Byte-compiled / optimized / DLL files __pycache__/ @@ -129,3 +130,7 @@ dmypy.json # Pyre type checker .pyre/ + +# Tutorials +account*.json +assets*.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0b32a2e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +repos: + - repo: https://github.com/pycqa/flake8 + rev: '3.9.2' # pick a git hash / tag to point to + hooks: + - id: flake8 + args: ['--ignore=E501,F403,F405,E126,E121,W503,E203', '.'] + exclude: ^(env|venv) + + - repo: https://github.com/psf/black + rev: 22.8.0 + hooks: + - id: black + args: ['.', '--check'] + exclude: ^(env|venv) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e0f4c01 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Change Log + +## 2.0.0 + +### Added + +* Added Tinyman V2 support (`tinyman.v2`). +* Added Staking support (`tinyman.staking`). + - It allows creating commitment transaction by `prepare_commit_transaction` and tracking commitments by `parse_commit_transaction`. +* Added `calculate_price_impact` function to `tinyman.utils`. +* Improved `TransactionGroup` class. + - Added `+` operator support for composability, it allows creating a new transaction group (`txn_group_1 + txn_group_2`). + - Added `id` property, it returns the transactions group id. + - Added `TransactionGroup.sign_with_logicsig` function and deprecated `TransactionGroup.sign_with_logicisg` because of the typo. + +### Changed + +* `get_program` (V1) is moved from `tinyman.utils` to `tinyman.v1.contracts`. +* `get_state_from_account_info` (V1) is moved from `tinyman.utils` to `tinyman.v1.utils`. + +### Removed + +* Deprecated `wait_for_confirmation` function is removed. `wait_for_confirmation` is added to [Algorand SDK](https://github.com/algorand/py-algorand-sdk). +* Drop Python 3.7 support. + diff --git a/README.md b/README.md index 2b6645b..309012b 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,150 @@ The SDK has been updated for Tinyman V1.1. ## Installation -tinyman-py-sdk is not yet released on PYPI. It can be installed directly from this repository with pip: +tinyman-py-sdk is not released on PYPI. It can be installed directly from this repository with pip: `pip install git+https://github.com/tinymanorg/tinyman-py-sdk.git` +## V2 + +## Sneak Preview + +```python +# examples/v2/sneak_preview.py + +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient + +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod) + +# Fetch our two assets of interest +USDC = client.fetch_asset(10458941) +ALGO = client.fetch_asset(0) + +# Fetch the pool we will work with +pool = client.fetch_pool(USDC, ALGO) +print(f"Pool Info: {pool.info()}") + +# Get a quote for a swap of 1 ALGO to USDC with 1% slippage tolerance +quote = pool.fetch_fixed_input_swap_quote(amount_in=ALGO(1_000_000), slippage=0.01) +print(quote) +print(f"USDC per ALGO: {quote.price}") +print(f"USDC per ALGO (worst case): {quote.price_with_slippage}") +``` + +## Tutorial + +You can find a tutorial under the `examples/v2/tutorial` folder. + +To run a step use `python ` such as `python 01_generate_account.py`. + +#### Prerequisites +1. [Generating an account](examples/v2/01_generate_account.py) +2. [Creating assets](examples/v2/02_create_assets.by) + +#### Steps + +3. [Bootstrapping a pool](examples/v2/03_bootstrap_pool.py) +4. [Adding initial liquidity to the pool](examples/v2/04_add_initial_liquidity.py) +5. [Adding flexible (add two asset with a flexible rate) liquidity to the pool](examples/v2/05_add_flexible_liquidity.py) +6. [Adding single asset (add only one asset) liquidity to the pool](examples/v2/06_add_single_asset_liquidity.py) +7. [Removing liquidity to the pool](examples/v2/07_remove_liquidity.py) +8. [Removing single asset(receive single asset) liquidity to the pool](examples/v2/08_single_asset_remove_liquidity.py) +9. [Swapping fixed-input](examples/v2/09_fixed_input_swap.py) +10. [Swapping fixed-output](examples/v2/10_fixed_output_swap.py) + +## Example Operations + +### Bootstrap + +```python +txn_group = pool.prepare_bootstrap_transactions() +txn_group.sign_with_private_key(
, ) +txn_info = txn_group.submit(algod, wait=True) +``` + +### Add Liquidity + +#### Initial Add Liquidity + +```python +quote = pool.fetch_initial_add_liquidity_quote( + amount_a=, + amount_b=, +) +txn_group = pool.prepare_add_liquidity_transactions_from_quote(quote) +txn_group.sign_with_private_key(
, ) +txn_info = txn_group.submit(algod, wait=True) +``` + +#### Flexible Add Liquidity + +```python +quote = pool.fetch_flexible_add_liquidity_quote( + amount_a=, + amount_b=, +) +txn_group = pool.prepare_add_liquidity_transactions_from_quote(quote=quote) +txn_group.sign_with_private_key(
, ) +txn_info = txn_group.submit(algod, wait=True) +``` + +#### Single Asset Add Liquidity + +```python +quote = pool.fetch_single_asset_add_liquidity_quote(amount_a=) +txn_group = pool.prepare_add_liquidity_transactions_from_quote(quote=quote) +txn_group.sign_with_private_key(
, ) +txn_info = txn_group.submit(algod, wait=True) +``` + +### Remove Liquidity + +#### Remove Liquidity + +```python +quote = pool.fetch_remove_liquidity_quote( + pool_token_asset_in=, +) +txn_group = pool.prepare_remove_liquidity_transactions_from_quote(quote=quote) +txn_group.sign_with_private_key(
, ) +txn_info = txn_group.submit(algod, wait=True) +``` + +#### Single Asset Remove Liquidity + +```python +quote = pool.fetch_single_asset_remove_liquidity_quote( + pool_token_asset_in=, + output_asset=, +) +txn_group = pool.prepare_remove_liquidity_transactions_from_quote(quote=quote) +txn_group.sign_with_private_key(
, ) +txn_info = txn_group.submit(algod, wait=True) +``` + +### Swap + +#### Fixed Input Swap + +```python +quote = pool.fetch_fixed_input_swap_quote(amount_in=) +txn_group = pool.prepare_swap_transactions_from_quote(quote=quote) +txn_group.sign_with_private_key(
, ) +txn_info = txn_group.submit(algod, wait=True) +``` + +#### Fixed Output Swap + +```python +quote = pool.fetch_fixed_output_swap_quote(amount_in=) +txn_group = pool.prepare_swap_transactions_from_quote(quote=quote) +txn_group.sign_with_private_key(
, ) +txn_info = txn_group.submit(algod, wait=True) +``` + +## V1.1 ## Sneak Preview @@ -48,7 +188,7 @@ print(f'TINYUSDC per ALGO (worst case): {quote.price_with_slippage}') ## Examples ### Basic Swapping -[swapping1.py](examples/swapping1.py) +[swapping1.py](examples/v1/swapping1.py) This example demonstrates basic functionality including: * retrieving Pool details * getting a swap quote @@ -58,16 +198,16 @@ This example demonstrates basic functionality including: * checking excess amounts * preparing redeem transactions -[swapping1_less_convenience.py](examples/swapping1_less_convenience.py) -This example has exactly the same functionality as [swapping1.py](examples/swapping1.py) but is purposely more verbose, using less convenience functions. +[swapping1_less_convenience.py](examples/v1/swapping1_less_convenience.py) +This example has exactly the same functionality as [swapping1.py](examples/v1/swapping1.py) but is purposely more verbose, using less convenience functions. ### Basic Pooling -[pooling1.py](examples/pooling1.py) +[pooling1.py](examples/v1/pooling1.py) This example demonstrates retrieving the current pool position/share for an address. ### Basic Add Liquidity (Minting) -[add_liquidity1.py](examples/add_liquidity1.py) +[add_liquidity1.py](examples/v1/add_liquidity1.py) This example demonstrates add liquidity to an existing pool. ### Basic Burning @@ -119,9 +259,9 @@ for i, txn in enumerate(transaction_group.transactions): transaction_group.signed_transactions[i] = kmd.sign_transaction(handle, KMD_WALLET_PASSWORD, txn) ``` -A User account LogicSig can also be used in a similar way or using the `sign_with_logicisg` convenience method: +A User account LogicSig can also be used in a similar way or using the `sign_with_logicsig` convenience method: ```python -transaction_group.sign_with_logicisg(logicsig) +transaction_group.sign_with_logicsig(logicsig) ``` ### Submission diff --git a/asc.json b/asc.json new file mode 100644 index 0000000..a97408d --- /dev/null +++ b/asc.json @@ -0,0 +1,32 @@ +{ + "repo": "https://github.com/tinymanorg/tinyman-staking", + "ref": "main", + "contracts": { + "staking_app": { + "type": "app", + "approval_program": { + "bytecode": "BSADAAEIJgcIZW5kX3RpbWUGYXNzZXRzBG1pbnMMdmVyaWZpY2F0aW9uCGJhbGFuY2UgAmlkD3Byb2dyYW1fY291bnRlcjEZIhJAAC8xGSMSQAAZMRmBAhJAAicxGYEEEkACITEZgQUSQAIfADYaAIAFc2V0dXASQAGZADYaAIAGY3JlYXRlEkAAijYaAIAGY29tbWl0EkAAfTYaAIAFY2xhaW0SQAEDNhoAgAZ1cGRhdGUSQAE8NhoAgA51cGRhdGVfcmV3YXJkcxJAAN82GgCADXVwZGF0ZV9hc3NldHMSQAD4NhoAgAtlbmRfcHJvZ3JhbRJAAPI2GgArEkAA9TYaAIALbG9nX2JhbGFuY2USQADvACNDIyhiMgcNRCMpYiJKJAtbNjAAEkAACiMISYEODERC/+tMSDUBIypiNAEkC1s1AjYaARdBAAg2GgEXNAIPRCI2MABwAERJFicETFCwNhoBFw9EgBN0aW55bWFuU3Rha2luZy92MTpiMQVRABMSRDEFVxMANQE0ASJbIycFYhJENAEkWzYwABJENAGBEFs2GgEXEkQjQyNDIoACcjE2GgFmIoACcjI2GgJmIoACcjM2GgNmIoACcjQ2GgRmIoACcjU2GgVmI0MiKTYaAWYiKjYaAmYjQyIoNhoBF2YjQyNDMgkxABJEIis2GgFmI0MiNjAAcABESUQWJwRMULAjQycGZCMINQEnBjQBZyInBTQBZiKAA3VybDYaAWYigA9yZXdhcmRfYXNzZXRfaWQ2GgIXZiKADXJld2FyZF9wZXJpb2Q2GgMXZiKACnN0YXJ0X3RpbWU2GgQXZiIoNhoFF2YiKTYaBmYiKjYaB2YjQyNDMgkxABJDMgkxABJDAA==", + "address": "KJ3W4IB66Q4ZITCNVABJXAV4I4HKWSZIJMD6BTFATQAWO5AKOV5VMZ6OFI", + "size": 658, + "variables": [], + "source": "https://github.com/tinymanorg/tinyman-staking/tree/main/contracts/staking.teal" + }, + "clear_program": { + "bytecode": "BIEB", + "address": "P7GEWDXXW5IONRW6XRIRVPJCT2XXEQGOBGG65VJPBUOYZEJCBZWTPHS3VQ", + "size": 3, + "variables": [], + "source": "https://github.com/tinymanorg/tinyman-staking/tree/main/contracts/clear_state.teal" + }, + "global_state_schema": { + "num_uints": 2, + "num_byte_slices": 2 + }, + "local_state_schema": { + "num_uints": 5, + "num_byte_slices": 11 + }, + "name": "staking_app" + } + } +} \ No newline at end of file diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/staking/commitments.py b/examples/staking/commitments.py new file mode 100644 index 0000000..d914e5e --- /dev/null +++ b/examples/staking/commitments.py @@ -0,0 +1,13 @@ +import requests +from tinyman.staking import parse_commit_transaction +from tinyman.staking.constants import TESTNET_STAKING_APP_ID + +app_id = TESTNET_STAKING_APP_ID +result = requests.get( + f"https://indexer.testnet.algoexplorerapi.io/v2/transactions?application-id={app_id}&latest=50" +).json() +for txn in result["transactions"]: + commit = parse_commit_transaction(txn, app_id) + if commit: + print(commit) + print() diff --git a/examples/staking/make_commitment.py b/examples/staking/make_commitment.py new file mode 100644 index 0000000..7d1699a --- /dev/null +++ b/examples/staking/make_commitment.py @@ -0,0 +1,40 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. + +from tinyman.v1.client import TinymanTestnetClient + +from tinyman.staking import prepare_commit_transaction + +# 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", # Use algosdk.mnemonic.to_private_key(mnemonic) if necessary +} + +client = TinymanTestnetClient(user_address=account["address"]) + +# Fetch our two assets of interest +TINYUSDC = client.fetch_asset(21582668) +ALGO = client.fetch_asset(0) + +# Fetch the pool we will work with +pool = client.fetch_pool(TINYUSDC, ALGO) + + +sp = client.algod.suggested_params() + +txn_group = prepare_commit_transaction( + app_id=client.staking_app_id, + program_id=1, + program_account="B4XVZ226UPFEIQBPIY6H454YA4B7HYXGEM7UDQR2RJP66HVLOARZTUTS6Q", + pool_asset_id=pool.liquidity_asset.id, + amount=600_000_000, + sender=account["address"], + suggested_params=sp, +) + +txn_group.sign_with_private_key(account["address"], account["private_key"]) +result = client.submit(txn_group, wait=True) +print(result) diff --git a/examples/add_liquidity1.py b/examples/v1/add_liquidity1.py similarity index 69% rename from examples/add_liquidity1.py rename to examples/v1/add_liquidity1.py index c7b8fd5..8d45996 100644 --- a/examples/add_liquidity1.py +++ b/examples/v1/add_liquidity1.py @@ -9,12 +9,14 @@ # 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', # Use algosdk.mnemonic.to_private_key(mnemonic) if necessary + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", # Use algosdk.mnemonic.to_private_key(mnemonic) if necessary } -algod = AlgodClient('', 'http://localhost:8080', headers={'User-Agent': 'algosdk'}) -client = TinymanTestnetClient(algod_client=algod, user_address=account['address']) +algod = AlgodClient( + "", "http://localhost:8080", headers={"User-Agent": "algosdk"} +) +client = TinymanTestnetClient(algod_client=algod, user_address=account["address"]) # By default all subsequent operations are on behalf of user_address # Fetch our two assets of interest @@ -33,21 +35,23 @@ if quote.amounts_in[ALGO] < 5_000_000: # Prepare the mint transactions from the quote and sign them transaction_group = pool.prepare_mint_transactions_from_quote(quote) - transaction_group.sign_with_private_key(account['address'], account['private_key']) + transaction_group.sign_with_private_key(account["address"], account["private_key"]) result = client.submit(transaction_group, wait=True) # Check if any excess liquidity asset remaining after the mint excess = pool.fetch_excess_amounts() if pool.liquidity_asset in excess: amount = excess[pool.liquidity_asset] - print(f'Excess: {amount}') + print(f"Excess: {amount}") if amount > 1_000_000: transaction_group = pool.prepare_redeem_transactions(amount) - transaction_group.sign_with_private_key(account['address'], account['private_key']) + transaction_group.sign_with_private_key( + account["address"], account["private_key"] + ) result = client.submit(transaction_group, wait=True) info = pool.fetch_pool_position() -share = info['share'] * 100 -print(f'Pool Tokens: {info[pool.liquidity_asset]}') -print(f'Assets: {info[TINYUSDC]}, {info[ALGO]}') -print(f'Share of pool: {share:.3f}%') \ No newline at end of file +share = info["share"] * 100 +print(f"Pool Tokens: {info[pool.liquidity_asset]}") +print(f"Assets: {info[TINYUSDC]}, {info[ALGO]}") +print(f"Share of pool: {share:.3f}%") diff --git a/examples/pooling1.py b/examples/v1/pooling1.py similarity index 63% rename from examples/pooling1.py rename to examples/v1/pooling1.py index b3c4c12..26114be 100644 --- a/examples/pooling1.py +++ b/examples/v1/pooling1.py @@ -9,12 +9,14 @@ # 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', # Use algosdk.mnemonic.to_private_key(mnemonic) if necessary + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", # Use algosdk.mnemonic.to_private_key(mnemonic) if necessary } -algod = AlgodClient('', 'http://localhost:8080', headers={'User-Agent': 'algosdk'}) -client = TinymanTestnetClient(algod_client=algod, user_address=account['address']) +algod = AlgodClient( + "", "http://localhost:8080", headers={"User-Agent": "algosdk"} +) +client = TinymanTestnetClient(algod_client=algod, user_address=account["address"]) # By default all subsequent operations are on behalf of user_address # Fetch our two assets of interest @@ -25,7 +27,7 @@ pool = client.fetch_pool(TINYUSDC, ALGO) info = pool.fetch_pool_position() -share = info['share'] * 100 -print(f'Pool Tokens: {info[pool.liquidity_asset]}') -print(f'Assets: {info[TINYUSDC]}, {info[ALGO]}') -print(f'Share of pool: {share:.3f}%') +share = info["share"] * 100 +print(f"Pool Tokens: {info[pool.liquidity_asset]}") +print(f"Assets: {info[TINYUSDC]}, {info[ALGO]}") +print(f"Share of pool: {share:.3f}%") diff --git a/examples/swapping1.py b/examples/v1/swapping1.py similarity index 68% rename from examples/swapping1.py rename to examples/v1/swapping1.py index 68d22b1..028aea5 100644 --- a/examples/swapping1.py +++ b/examples/v1/swapping1.py @@ -11,19 +11,21 @@ # 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', # Use algosdk.mnemonic.to_private_key(mnemonic) if necessary + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", # Use algosdk.mnemonic.to_private_key(mnemonic) if necessary } -algod = AlgodClient('', 'http://localhost:8080', headers={'User-Agent': 'algosdk'}) -client = TinymanTestnetClient(algod_client=algod, user_address=account['address']) +algod = AlgodClient( + "", "http://localhost:8080", headers={"User-Agent": "algosdk"} +) +client = TinymanTestnetClient(algod_client=algod, user_address=account["address"]) # By default all subsequent operations are on behalf of user_address # Check if the account is opted into Tinyman and optin if necessary -if(not client.is_opted_in()): - print('Account not opted into app, opting in now..') +if not client.is_opted_in(): + print("Account not opted into app, opting in now..") transaction_group = client.prepare_app_optin_transactions() - transaction_group.sign_with_private_key(account['address'], account['private_key']) + transaction_group.sign_with_private_key(account["address"], account["private_key"]) result = client.submit(transaction_group, wait=True) @@ -38,16 +40,16 @@ # Get a quote for a swap of 1 ALGO to TINYUSDC with 1% slippage tolerance quote = pool.fetch_fixed_input_swap_quote(ALGO(1_000_000), slippage=0.01) print(quote) -print(f'TINYUSDC per ALGO: {quote.price}') -print(f'TINYUSDC per ALGO (worst case): {quote.price_with_slippage}') +print(f"TINYUSDC per ALGO: {quote.price}") +print(f"TINYUSDC per ALGO (worst case): {quote.price_with_slippage}") # We only want to sell if ALGO is > 180 TINYUSDC (It's testnet!) if quote.price_with_slippage > 180: - print(f'Swapping {quote.amount_in} to {quote.amount_out_with_slippage}') + print(f"Swapping {quote.amount_in} to {quote.amount_out_with_slippage}") # Prepare a transaction group transaction_group = pool.prepare_swap_transactions_from_quote(quote) # Sign the group with our key - transaction_group.sign_with_private_key(account['address'], account['private_key']) + transaction_group.sign_with_private_key(account["address"], account["private_key"]) # Submit transactions to the network and wait for confirmation result = client.submit(transaction_group, wait=True) @@ -55,9 +57,11 @@ excess = pool.fetch_excess_amounts() if TINYUSDC in excess: amount = excess[TINYUSDC] - print(f'Excess: {amount}') + print(f"Excess: {amount}") # We might just let the excess accumulate rather than redeeming if its < 1 TinyUSDC if amount > 1_000_000: transaction_group = pool.prepare_redeem_transactions(amount) - transaction_group.sign_with_private_key(account['address'], account['private_key']) + transaction_group.sign_with_private_key( + account["address"], account["private_key"] + ) result = client.submit(transaction_group, wait=True) diff --git a/examples/swapping1_less_convenience.py b/examples/v1/swapping1_less_convenience.py similarity index 69% rename from examples/swapping1_less_convenience.py rename to examples/v1/swapping1_less_convenience.py index 90796fa..6345c4e 100644 --- a/examples/swapping1_less_convenience.py +++ b/examples/v1/swapping1_less_convenience.py @@ -16,12 +16,14 @@ # 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', # Use algosdk.mnemonic.to_private_key(mnemonic) if necessary + "address": "ALGORAND_ADDRESS_HERE", + "private_key": "base64_private_key_here", # Use algosdk.mnemonic.to_private_key(mnemonic) if necessary } -algod = AlgodClient('', 'http://localhost:8080', headers={'User-Agent': 'algosdk'}) +algod = AlgodClient( + "", "http://localhost:8080", headers={"User-Agent": "algosdk"} +) client = TinymanClient( algod_client=algod, @@ -30,19 +32,19 @@ # Check if the account is opted into Tinyman and optin if necessary -if(not client.is_opted_in(account['address'])): - print('Account not opted into app, opting in now..') - transaction_group = client.prepare_app_optin_transactions(account['address']) +if not client.is_opted_in(account["address"]): + print("Account not opted into app, opting in now..") + transaction_group = client.prepare_app_optin_transactions(account["address"]) for i, txn in enumerate(transaction_group.transactions): - if txn.sender == account['address']: - transaction_group.signed_transactions[i] = txn.sign(account['private_key']) + if txn.sender == account["address"]: + transaction_group.signed_transactions[i] = txn.sign(account["private_key"]) txid = client.algod.send_transactions(transaction_group.signed_transactions) wait_for_confirmation(algod, txid) # Fetch our two assets of interest -TINYUSDC = Asset(id=21582668, name='TinyUSDC', unit_name='TINYUSDC', decimals=6) -ALGO = Asset(id=0, name='Algo', unit_name='ALGO', decimals=6) +TINYUSDC = Asset(id=21582668, name="TinyUSDC", unit_name="TINYUSDC", decimals=6) +ALGO = Asset(id=0, name="Algo", unit_name="ALGO", decimals=6) # Create the pool we will work with and fetch its on-chain state pool = Pool(client, asset_a=TINYUSDC, asset_b=ALGO, fetch=True) @@ -51,37 +53,41 @@ # Get a quote for a swap of 1 ALGO to TINYUSDC with 1% slippage tolerance quote = pool.fetch_fixed_input_swap_quote(ALGO(1_000_000), slippage=0.01) print(quote) -print(f'TINYUSDC per ALGO: {quote.price}') -print(f'TINYUSDC per ALGO (worst case): {quote.price_with_slippage}') +print(f"TINYUSDC per ALGO: {quote.price}") +print(f"TINYUSDC per ALGO (worst case): {quote.price_with_slippage}") # We only want to sell if ALGO is > 180 TINYUSDC (It's testnet!) if quote.price_with_slippage > 180: - print(f'Swapping {quote.amount_in} to {quote.amount_out_with_slippage}') + print(f"Swapping {quote.amount_in} to {quote.amount_out_with_slippage}") # Prepare a transaction group transaction_group = pool.prepare_swap_transactions( amount_in=quote.amount_in, amount_out=quote.amount_out_with_slippage, - swap_type='fixed-input', - swapper_address=account['address'], + swap_type="fixed-input", + swapper_address=account["address"], ) # Sign the group with our key for i, txn in enumerate(transaction_group.transactions): - if txn.sender == account['address']: - transaction_group.signed_transactions[i] = txn.sign(account['private_key']) + if txn.sender == account["address"]: + transaction_group.signed_transactions[i] = txn.sign(account["private_key"]) txid = algod.send_transactions(transaction_group.signed_transactions) wait_for_confirmation(algod, txid) # Check if any excess remaining after the swap - excess = pool.fetch_excess_amounts(account['address']) + excess = pool.fetch_excess_amounts(account["address"]) if TINYUSDC.id in excess: amount = excess[TINYUSDC.id] - print(f'Excess: {amount}') + print(f"Excess: {amount}") # We might just let the excess accumulate rather than redeeming if its < 1 TinyUSDC if amount > 1_000_000: - transaction_group = pool.prepare_redeem_transactions(amount, account['address']) + transaction_group = pool.prepare_redeem_transactions( + amount, account["address"] + ) # Sign the group with our key for i, txn in enumerate(transaction_group.transactions): - if txn.sender == account['address']: - transaction_group.signed_transactions[i] = txn.sign(account['private_key']) + if txn.sender == account["address"]: + transaction_group.signed_transactions[i] = txn.sign( + account["private_key"] + ) txid = algod.send_transactions(transaction_group.signed_transactions) wait_for_confirmation(algod, txid) diff --git a/examples/v2/__init__.py b/examples/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/v2/sneak_preview.py b/examples/v2/sneak_preview.py new file mode 100644 index 0000000..bf07e58 --- /dev/null +++ b/examples/v2/sneak_preview.py @@ -0,0 +1,22 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient + +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod) + +# Fetch our two assets of interest +USDC = client.fetch_asset(10458941) +ALGO = client.fetch_asset(0) + +# Fetch the pool we will work with +pool = client.fetch_pool(USDC, ALGO) +print(f"Pool Info: {pool.info()}") + +# Get a quote for a swap of 1 ALGO to USDC with 1% slippage tolerance +quote = pool.fetch_fixed_input_swap_quote(amount_in=ALGO(1_000_000), slippage=0.01) +print(quote) +print(f"USDC per ALGO: {quote.price}") +print(f"USDC per ALGO (worst case): {quote.price_with_slippage}") diff --git a/examples/v2/tutorial/01_generate_account.py b/examples/v2/tutorial/01_generate_account.py new file mode 100644 index 0000000..494ceab --- /dev/null +++ b/examples/v2/tutorial/01_generate_account.py @@ -0,0 +1,35 @@ +import json +import os + +from algosdk.account import generate_account +from algosdk.mnemonic import from_private_key + +from examples.v2.tutorial.common import get_account_file_path + +account_file_path = get_account_file_path() + +try: + size = os.path.getsize(account_file_path) +except FileNotFoundError: + size = 0 +else: + if size > 0: + raise Exception(f"The file({account_file_path}) is not empty") + +private_key, address = generate_account() +mnemonic = from_private_key(private_key) + +account_data = { + "address": address, + "private_key": private_key, + "mnemonic": mnemonic, +} + +with open(account_file_path, "w", encoding="utf-8") as f: + json.dump(account_data, f, ensure_ascii=False, indent=4) + +print(f"Generated Account: {address}") +# Fund the account +print( + f"Go to https://bank.testnet.algorand.network/?account={address} and fund your account." +) diff --git a/examples/v2/tutorial/02_create_assets.py b/examples/v2/tutorial/02_create_assets.py new file mode 100644 index 0000000..a9e9a32 --- /dev/null +++ b/examples/v2/tutorial/02_create_assets.py @@ -0,0 +1,48 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +import json +import os + +from examples.v2.tutorial.common import ( + get_account, + get_assets_file_path, + create_asset, +) +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient + + +assets_file_path = get_assets_file_path() + +try: + size = os.path.getsize(assets_file_path) +except FileNotFoundError: + size = 0 +else: + if size > 0: + raise Exception(f"The file({assets_file_path}) is not empty") + +account = get_account() +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod, user_address=account["address"]) + +account_info = algod.account_info(account["address"]) +if not account_info["amount"]: + print( + f"Go to https://bank.testnet.algorand.network/?account={account['address']} and fund your account." + ) + exit(1) + +ASSET_A_ID = create_asset(algod, account["address"], account["private_key"]) +ASSET_B_ID = create_asset(algod, account["address"], account["private_key"]) + +assets_data = {"ids": [ASSET_A_ID, ASSET_B_ID]} + +with open(assets_file_path, "w", encoding="utf-8") as f: + json.dump(assets_data, f, ensure_ascii=False, indent=4) + +print(f"Generated Assets: {[ASSET_A_ID, ASSET_B_ID]}") +print("View on Algoexplorer:") +print(f"https://testnet.algoexplorer.io/asset/{ASSET_A_ID}") +print(f"https://testnet.algoexplorer.io/asset/{ASSET_B_ID}") diff --git a/examples/v2/tutorial/03_bootstrap_pool.py b/examples/v2/tutorial/03_bootstrap_pool.py new file mode 100644 index 0000000..d60307e --- /dev/null +++ b/examples/v2/tutorial/03_bootstrap_pool.py @@ -0,0 +1,39 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from pprint import pprint +from urllib.parse import quote_plus + +from examples.v2.tutorial.common import get_account, get_assets +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient + + +account = get_account() +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod, user_address=account["address"]) + +# Fetch assets +ASSET_A_ID, ASSET_B_ID = get_assets()["ids"] +ASSET_A = client.fetch_asset(ASSET_A_ID) +ASSET_B = client.fetch_asset(ASSET_B_ID) + +# Fetch the pool +pool = client.fetch_pool(ASSET_A_ID, ASSET_B_ID) +print(pool) + +# Get transaction group +txn_group = pool.prepare_bootstrap_transactions() + +# Sign +txn_group.sign_with_private_key(account["address"], account["private_key"]) + +# Submit transactions to the network and wait for confirmation +txn_info = client.submit(txn_group, wait=True) + +print("Transaction Info") +pprint(txn_info) + +print( + f"Check the transaction group on Algoexplorer: https://testnet.algoexplorer.io/tx/group/{quote_plus(txn_group.id)}" +) diff --git a/examples/v2/tutorial/04_add_initial_liquidity.py b/examples/v2/tutorial/04_add_initial_liquidity.py new file mode 100644 index 0000000..73546ec --- /dev/null +++ b/examples/v2/tutorial/04_add_initial_liquidity.py @@ -0,0 +1,56 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from pprint import pprint +from urllib.parse import quote_plus + +from tinyman.assets import AssetAmount + +from examples.v2.tutorial.common import get_account, get_assets +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient + + +account = get_account() +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod, user_address=account["address"]) + +ASSET_A_ID, ASSET_B_ID = get_assets()["ids"] +ASSET_A = client.fetch_asset(ASSET_A_ID) +ASSET_B = client.fetch_asset(ASSET_B_ID) +pool = client.fetch_pool(ASSET_A_ID, ASSET_B_ID) + +# Opt-in to the pool token +txn_group_1 = pool.prepare_pool_token_asset_optin_transactions() + +# Get initial add liquidity quote +quote = pool.fetch_initial_add_liquidity_quote( + amount_a=AssetAmount(pool.asset_1, 10_000_000), + amount_b=AssetAmount(pool.asset_2, 10_000_000), +) +print("Quote:") +print(quote) + +txn_group_2 = pool.prepare_add_liquidity_transactions_from_quote(quote) + +# You can merge the transaction groups +txn_group = txn_group_1 + txn_group_2 + +# Sign +txn_group.sign_with_private_key(account["address"], account["private_key"]) + +# Submit transactions to the network and wait for confirmation +txn_info = client.submit(txn_group, wait=True) +print("Transaction Info") +pprint(txn_info) + +print( + f"Check the transaction group on Algoexplorer: https://testnet.algoexplorer.io/tx/group/{quote_plus(txn_group.id)}" +) + +pool.refresh() +pool_position = pool.fetch_pool_position() +share = pool_position["share"] * 100 +print(f"Pool Tokens: {pool_position[pool.pool_token_asset]}") +print(f"Assets: {pool_position[ASSET_A]}, {pool_position[ASSET_B]}") +print(f"Share of pool: {share:.3f}%") diff --git a/examples/v2/tutorial/05_add_flexible_liquidity.py b/examples/v2/tutorial/05_add_flexible_liquidity.py new file mode 100644 index 0000000..d862d88 --- /dev/null +++ b/examples/v2/tutorial/05_add_flexible_liquidity.py @@ -0,0 +1,65 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from pprint import pprint +from urllib.parse import quote_plus + +from tinyman.assets import AssetAmount + +from examples.v2.tutorial.common import get_account, get_assets +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient + + +account = get_account() +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod, user_address=account["address"]) + +ASSET_A_ID, ASSET_B_ID = get_assets()["ids"] +ASSET_A = client.fetch_asset(ASSET_A_ID) +ASSET_B = client.fetch_asset(ASSET_B_ID) +pool = client.fetch_pool(ASSET_A_ID, ASSET_B_ID) + +# Add flexible liquidity (advanced) +# txn_group = pool.prepare_add_liquidity_transactions( +# amounts_in={ +# pool.asset_1: AssetAmount(pool.asset_1, 5_000_000), +# pool.asset_2: AssetAmount(pool.asset_2, 7_000_000) +# }, +# min_pool_token_asset_amount="Do your own calculation" +# ) + +quote = pool.fetch_flexible_add_liquidity_quote( + amount_a=AssetAmount(pool.asset_1, 10_000_000), + amount_b=AssetAmount(pool.asset_2, 5_000_000), +) + +print("\nQuote:") +print(quote) + +txn_group = pool.prepare_add_liquidity_transactions_from_quote(quote=quote) + +if not client.asset_is_opted_in(asset_id=pool.pool_token_asset.id): + # Opt-in to the pool token + opt_in_txn_group = pool.prepare_pool_token_asset_optin_transactions() + # You can merge the transaction groups + txn_group = txn_group + opt_in_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 = client.submit(txn_group, wait=True) +print("Transaction Info") +pprint(txn_info) + +print( + f"Check the transaction group on Algoexplorer: https://testnet.algoexplorer.io/tx/group/{quote_plus(txn_group.id)}" +) + +pool.refresh() +pool_position = pool.fetch_pool_position() +share = pool_position["share"] * 100 +print(f"Pool Tokens: {pool_position[pool.pool_token_asset]}") +print(f"Assets: {pool_position[ASSET_A]}, {pool_position[ASSET_B]}") +print(f"Share of pool: {share:.3f}%") diff --git a/examples/v2/tutorial/06_add_single_asset_liquidity.py b/examples/v2/tutorial/06_add_single_asset_liquidity.py new file mode 100644 index 0000000..68bd0b0 --- /dev/null +++ b/examples/v2/tutorial/06_add_single_asset_liquidity.py @@ -0,0 +1,64 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from pprint import pprint +from urllib.parse import quote_plus + +from tinyman.assets import AssetAmount + +from examples.v2.tutorial.common import get_account, get_assets +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient + + +account = get_account() +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod, user_address=account["address"]) + +ASSET_A_ID, ASSET_B_ID = get_assets()["ids"] +ASSET_A = client.fetch_asset(ASSET_A_ID) +ASSET_B = client.fetch_asset(ASSET_B_ID) +pool = client.fetch_pool(ASSET_A_ID, ASSET_B_ID) + +# Add flexible liquidity (advanced) +# txn_group = pool.prepare_add_liquidity_transactions( +# amounts_in={ +# pool.asset_1: AssetAmount(pool.asset_1, 5_000_000), +# pool.asset_2: AssetAmount(pool.asset_2, 7_000_000) +# }, +# min_pool_token_asset_amount="Do your own calculation" +# ) + +quote = pool.fetch_single_asset_add_liquidity_quote( + amount_a=AssetAmount(pool.asset_1, 10_000_000), +) + +print("\nAdd Liquidity Quote:") +print(quote) + +txn_group = pool.prepare_add_liquidity_transactions_from_quote(quote=quote) + +if not client.asset_is_opted_in(asset_id=pool.pool_token_asset.id): + # Opt-in to the pool token + opt_in_txn_group = pool.prepare_pool_token_asset_optin_transactions() + # You can merge the transaction groups + txn_group = txn_group + opt_in_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 = client.submit(txn_group, wait=True) +print("Transaction Info") +pprint(txn_info) + +print( + f"Check the transaction group on Algoexplorer: https://testnet.algoexplorer.io/tx/group/{quote_plus(txn_group.id)}" +) + +pool.refresh() +pool_position = pool.fetch_pool_position() +share = pool_position["share"] * 100 +print(f"Pool Tokens: {pool_position[pool.pool_token_asset]}") +print(f"Assets: {pool_position[ASSET_A]}, {pool_position[ASSET_B]}") +print(f"Share of pool: {share:.3f}%") diff --git a/examples/v2/tutorial/07_remove_liquidity.py b/examples/v2/tutorial/07_remove_liquidity.py new file mode 100644 index 0000000..b93e544 --- /dev/null +++ b/examples/v2/tutorial/07_remove_liquidity.py @@ -0,0 +1,51 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from pprint import pprint +from urllib.parse import quote_plus + +from examples.v2.tutorial.common import get_account, get_assets +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient + + +account = get_account() +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod, user_address=account["address"]) + +ASSET_A_ID, ASSET_B_ID = get_assets()["ids"] +ASSET_A = client.fetch_asset(ASSET_A_ID) +ASSET_B = client.fetch_asset(ASSET_B_ID) +pool = client.fetch_pool(ASSET_A_ID, ASSET_B_ID) + +position = pool.fetch_pool_position() +pool_token_asset_in = position[pool.pool_token_asset].amount // 4 + +quote = pool.fetch_remove_liquidity_quote( + pool_token_asset_in=pool_token_asset_in, +) + +print("\nRemove Liquidity Quote:") +print(quote) + +txn_group = pool.prepare_remove_liquidity_transactions_from_quote(quote=quote) + +# Sign +txn_group.sign_with_private_key(account["address"], account["private_key"]) + +# Submit transactions to the network and wait for confirmation +txn_info = client.submit(txn_group, wait=True) +print("Transaction Info") +pprint(txn_info) + +print( + f"Check the transaction group on Algoexplorer: https://testnet.algoexplorer.io/tx/group/{quote_plus(txn_group.id)}" +) + +pool.refresh() + +pool_position = pool.fetch_pool_position() +share = pool_position["share"] * 100 +print(f"Pool Tokens: {pool_position[pool.pool_token_asset]}") +print(f"Assets: {pool_position[ASSET_A]}, {pool_position[ASSET_B]}") +print(f"Share of pool: {share:.3f}%") diff --git a/examples/v2/tutorial/08_single_asset_remove_liquidity.py b/examples/v2/tutorial/08_single_asset_remove_liquidity.py new file mode 100644 index 0000000..7b2b6bc --- /dev/null +++ b/examples/v2/tutorial/08_single_asset_remove_liquidity.py @@ -0,0 +1,51 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from pprint import pprint +from urllib.parse import quote_plus + +from examples.v2.tutorial.common import get_account, get_assets +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient + + +account = get_account() +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod, user_address=account["address"]) + +ASSET_A_ID, ASSET_B_ID = get_assets()["ids"] +ASSET_A = client.fetch_asset(ASSET_A_ID) +ASSET_B = client.fetch_asset(ASSET_B_ID) +pool = client.fetch_pool(ASSET_A_ID, ASSET_B_ID) + +position = pool.fetch_pool_position() +pool_token_asset_in = position[pool.pool_token_asset].amount // 8 + +quote = pool.fetch_single_asset_remove_liquidity_quote( + pool_token_asset_in=pool_token_asset_in, + output_asset=pool.asset_1, +) + +print("\nSingle Asset Remove Liquidity Quote:") +print(quote) + +txn_group = pool.prepare_remove_liquidity_transactions_from_quote(quote=quote) + +# Sign +txn_group.sign_with_private_key(account["address"], account["private_key"]) + +# Submit transactions to the network and wait for confirmation +txn_info = client.submit(txn_group, wait=True) +print("Transaction Info") +pprint(txn_info) + +print( + f"Check the transaction group on Algoexplorer: https://testnet.algoexplorer.io/tx/group/{quote_plus(txn_group.id)}" +) + +pool.refresh() +pool_position = pool.fetch_pool_position() +share = pool_position["share"] * 100 +print(f"Pool Tokens: {pool_position[pool.pool_token_asset]}") +print(f"Assets: {pool_position[ASSET_A]}, {pool_position[ASSET_B]}") +print(f"Share of pool: {share:.3f}%") diff --git a/examples/v2/tutorial/09_fixed_input_swap.py b/examples/v2/tutorial/09_fixed_input_swap.py new file mode 100644 index 0000000..822c7c8 --- /dev/null +++ b/examples/v2/tutorial/09_fixed_input_swap.py @@ -0,0 +1,45 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from pprint import pprint +from urllib.parse import quote_plus + +from tinyman.assets import AssetAmount + +from examples.v2.tutorial.common import get_account, get_assets +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient + + +account = get_account() +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod, user_address=account["address"]) + +ASSET_A_ID, ASSET_B_ID = get_assets()["ids"] +ASSET_A = client.fetch_asset(ASSET_A_ID) +ASSET_B = client.fetch_asset(ASSET_B_ID) +pool = client.fetch_pool(ASSET_A_ID, ASSET_B_ID) + +position = pool.fetch_pool_position() +amount_in = AssetAmount(pool.asset_1, 1_000_000) + +quote = pool.fetch_fixed_input_swap_quote( + amount_in=amount_in, +) + +print("\nSwap Quote:") +print(quote) + +txn_group = pool.prepare_swap_transactions_from_quote(quote=quote) + +# Sign +txn_group.sign_with_private_key(account["address"], account["private_key"]) + +# Submit transactions to the network and wait for confirmation +txn_info = client.submit(txn_group, wait=True) +print("Transaction Info") +pprint(txn_info) + +print( + f"Check the transaction group on Algoexplorer: https://testnet.algoexplorer.io/tx/group/{quote_plus(txn_group.id)}" +) diff --git a/examples/v2/tutorial/10_fixed_output_swap.py b/examples/v2/tutorial/10_fixed_output_swap.py new file mode 100644 index 0000000..5694b06 --- /dev/null +++ b/examples/v2/tutorial/10_fixed_output_swap.py @@ -0,0 +1,45 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from pprint import pprint +from urllib.parse import quote_plus + +from tinyman.assets import AssetAmount + +from examples.v2.tutorial.common import get_account, get_assets +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient + + +account = get_account() +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod, user_address=account["address"]) + +ASSET_A_ID, ASSET_B_ID = get_assets()["ids"] +ASSET_A = client.fetch_asset(ASSET_A_ID) +ASSET_B = client.fetch_asset(ASSET_B_ID) +pool = client.fetch_pool(ASSET_A_ID, ASSET_B_ID) + +position = pool.fetch_pool_position() +amount_out = AssetAmount(pool.asset_1, 1_000_000) + +quote = pool.fetch_fixed_output_swap_quote( + amount_out=amount_out, +) + +print("\nSwap Quote:") +print(quote) + +txn_group = pool.prepare_swap_transactions_from_quote(quote=quote) + +# Sign +txn_group.sign_with_private_key(account["address"], account["private_key"]) + +# Submit transactions to the network and wait for confirmation +txn_info = client.submit(txn_group, wait=True) +print("Transaction Info") +pprint(txn_info) + +print( + f"Check the transaction group on Algoexplorer: https://testnet.algoexplorer.io/tx/group/{quote_plus(txn_group.id)}" +) diff --git a/examples/v2/tutorial/11_flash_loan_1_single_asset.py b/examples/v2/tutorial/11_flash_loan_1_single_asset.py new file mode 100644 index 0000000..87b556f --- /dev/null +++ b/examples/v2/tutorial/11_flash_loan_1_single_asset.py @@ -0,0 +1,65 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from pprint import pprint +from urllib.parse import quote_plus + +from tinyman.assets import AssetAmount + +from examples.v2.tutorial.common import get_account, get_assets +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient +from algosdk.future.transaction import AssetTransferTxn + +account = get_account() +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod, user_address=account["address"]) + +ASSET_A_ID, ASSET_B_ID = get_assets()["ids"] +ASSET_A = client.fetch_asset(ASSET_A_ID) +ASSET_B = client.fetch_asset(ASSET_B_ID) +pool = client.fetch_pool(ASSET_A_ID, ASSET_B_ID) + +position = pool.fetch_pool_position() + +quote = pool.fetch_flash_loan_quote( + loan_amount_a=AssetAmount(pool.asset_1, 1_000_000), + loan_amount_b=AssetAmount(pool.asset_2, 0), +) + +print("\nQuote:") +print(quote) + +account_info = algod.account_info(account["address"]) + +for asset in account_info["assets"]: + if asset["asset-id"] == pool.asset_1.id: + asset_1_balance = asset["amount"] + +# Transfer amount is equal to sum of initial account balance and loan amount +# this transaction demonstrates that you can use the total amount +transactions = [ + AssetTransferTxn( + sender=account["address"], + sp=algod.suggested_params(), + receiver=account["address"], + amt=asset_1_balance + quote.amounts_out[pool.asset_1].amount, + index=pool.asset_1.id, + ) +] + +txn_group = pool.prepare_flash_loan_transactions_from_quote( + quote=quote, transactions=transactions +) + +# Sign +txn_group.sign_with_private_key(account["address"], account["private_key"]) + +# Submit transactions to the network and wait for confirmation +txn_info = client.submit(txn_group, wait=True) +print("Transaction Info") +pprint(txn_info) + +print( + f"Check the transaction group on Algoexplorer: https://testnet.algoexplorer.io/tx/group/{quote_plus(txn_group.id)}" +) diff --git a/examples/v2/tutorial/12_flash_loan_2_multiple_assets.py b/examples/v2/tutorial/12_flash_loan_2_multiple_assets.py new file mode 100644 index 0000000..7189f00 --- /dev/null +++ b/examples/v2/tutorial/12_flash_loan_2_multiple_assets.py @@ -0,0 +1,74 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from pprint import pprint +from urllib.parse import quote_plus + +from tinyman.assets import AssetAmount + +from examples.v2.tutorial.common import get_account, get_assets +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient +from algosdk.future.transaction import AssetTransferTxn + +account = get_account() +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod, user_address=account["address"]) + +ASSET_A_ID, ASSET_B_ID = get_assets()["ids"] +ASSET_A = client.fetch_asset(ASSET_A_ID) +ASSET_B = client.fetch_asset(ASSET_B_ID) +pool = client.fetch_pool(ASSET_A_ID, ASSET_B_ID) + +position = pool.fetch_pool_position() + +quote = pool.fetch_flash_loan_quote( + loan_amount_a=AssetAmount(pool.asset_1, 3_000_000), + loan_amount_b=AssetAmount(pool.asset_2, 2_000_000), +) + +print("\nQuote:") +print(quote) + +account_info = algod.account_info(account["address"]) + +for asset in account_info["assets"]: + if asset["asset-id"] == pool.asset_1.id: + asset_1_balance = asset["amount"] + if asset["asset-id"] == pool.asset_1.id: + asset_2_balance = asset["amount"] + +# Transfer amounts are equal to sum of initial account balance and loan amount +# These transactions demonstrate that you can use the total amount +transactions = [ + AssetTransferTxn( + sender=account["address"], + sp=algod.suggested_params(), + receiver=account["address"], + amt=asset_1_balance + quote.amounts_out[pool.asset_1].amount, + index=pool.asset_1.id, + ), + AssetTransferTxn( + sender=account["address"], + sp=algod.suggested_params(), + receiver=account["address"], + amt=asset_2_balance + quote.amounts_out[pool.asset_2].amount, + index=pool.asset_2.id, + ), +] + +txn_group = pool.prepare_flash_loan_transactions_from_quote( + quote=quote, transactions=transactions +) + +# Sign +txn_group.sign_with_private_key(account["address"], account["private_key"]) + +# Submit transactions to the network and wait for confirmation +txn_info = client.submit(txn_group, wait=True) +print("Transaction Info") +pprint(txn_info) + +print( + f"Check the transaction group on Algoexplorer: https://testnet.algoexplorer.io/tx/group/{quote_plus(txn_group.id)}" +) diff --git a/examples/v2/tutorial/13_flash_swap_1.py b/examples/v2/tutorial/13_flash_swap_1.py new file mode 100644 index 0000000..dcc551a --- /dev/null +++ b/examples/v2/tutorial/13_flash_swap_1.py @@ -0,0 +1,110 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from pprint import pprint +from urllib.parse import quote_plus + +from algosdk.future.transaction import AssetTransferTxn, PaymentTxn + +from examples.v2.tutorial.common import get_account, get_assets +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient +from tinyman.v2.flash_swap import prepare_flash_swap_transactions +from tinyman.v2.formulas import calculate_flash_swap_asset_2_payment_amount + +account = get_account() +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod, user_address=account["address"]) + +ASSET_A_ID, ASSET_B_ID = get_assets()["ids"] +ASSET_A = client.fetch_asset(ASSET_A_ID) +ASSET_B = client.fetch_asset(ASSET_B_ID) +pool = client.fetch_pool(ASSET_A_ID, ASSET_B_ID) + +suggested_params = algod.suggested_params() +account_info = algod.account_info(account["address"]) + +for asset in account_info["assets"]: + if asset["asset-id"] == pool.asset_1.id: + balance = asset["amount"] + +asset_1_loan_amount = 1_000_000 +asset_2_loan_amount = 0 +asset_1_payment_amount = 0 +asset_2_payment_amount = calculate_flash_swap_asset_2_payment_amount( + asset_1_reserves=pool.asset_1_reserves, + asset_2_reserves=pool.asset_2_reserves, + total_fee_share=pool.total_fee_share, + protocol_fee_ratio=pool.protocol_fee_ratio, + asset_1_loan_amount=asset_1_loan_amount, + asset_2_loan_amount=asset_2_loan_amount, + asset_1_payment_amount=asset_1_payment_amount, +) + +# Transfer amount is equal to sum of initial account balance and loan amount +# This transaction demonstrate that you can use the total amount +transfer_amount = balance + asset_1_loan_amount +transactions = [ + AssetTransferTxn( + sender=account["address"], + sp=suggested_params, + receiver=account["address"], + amt=transfer_amount, + index=pool.asset_1.id, + ) +] + +if asset_1_payment_amount: + transactions.append( + AssetTransferTxn( + sender=account["address"], + sp=suggested_params, + receiver=pool.address, + index=pool.asset_1.id, + amt=asset_1_payment_amount, + ) + ) + +if asset_2_payment_amount: + if pool.asset_2.id: + transactions.append( + AssetTransferTxn( + sender=account["address"], + sp=suggested_params, + receiver=pool.address, + index=pool.asset_2.id, + amt=asset_2_payment_amount, + ) + ) + else: + transactions.append( + PaymentTxn( + sender=account["address"], + sp=suggested_params, + receiver=pool.address, + amt=asset_2_payment_amount, + ) + ) + +txn_group = prepare_flash_swap_transactions( + validator_app_id=pool.validator_app_id, + asset_1_id=pool.asset_1.id, + asset_2_id=pool.asset_2.id, + asset_1_loan_amount=asset_1_loan_amount, + asset_2_loan_amount=asset_2_loan_amount, + transactions=transactions, + suggested_params=suggested_params, + sender=account["address"], +) + +# Sign +txn_group.sign_with_private_key(account["address"], account["private_key"]) + +# Submit transactions to the network and wait for confirmation +txn_info = client.submit(txn_group, wait=True) +print("Transaction Info") +pprint(txn_info) + +print( + f"Check the transaction group on Algoexplorer: https://testnet.algoexplorer.io/tx/group/{quote_plus(txn_group.id)}" +) diff --git a/examples/v2/tutorial/14_flash_swap_2.py b/examples/v2/tutorial/14_flash_swap_2.py new file mode 100644 index 0000000..f7e3809 --- /dev/null +++ b/examples/v2/tutorial/14_flash_swap_2.py @@ -0,0 +1,111 @@ +# This sample is provided for demonstration purposes only. +# It is not intended for production use. +# This example does not constitute trading advice. +from pprint import pprint +from urllib.parse import quote_plus + +from algosdk.future.transaction import AssetTransferTxn, PaymentTxn + +from examples.v2.tutorial.common import get_account, get_assets +from examples.v2.utils import get_algod +from tinyman.v2.client import TinymanV2TestnetClient +from tinyman.v2.flash_swap import prepare_flash_swap_transactions +from tinyman.v2.formulas import calculate_flash_swap_asset_1_payment_amount + +account = get_account() +algod = get_algod() +client = TinymanV2TestnetClient(algod_client=algod, user_address=account["address"]) + +ASSET_A_ID, ASSET_B_ID = get_assets()["ids"] +ASSET_A = client.fetch_asset(ASSET_A_ID) +ASSET_B = client.fetch_asset(ASSET_B_ID) +pool = client.fetch_pool(ASSET_A_ID, ASSET_B_ID) + +suggested_params = algod.suggested_params() +account_info = algod.account_info(account["address"]) + +for asset in account_info["assets"]: + if asset["asset-id"] == pool.asset_1.id: + balance = asset["amount"] + +asset_1_loan_amount = 1_000_000 +asset_2_loan_amount = 0 +asset_2_payment_amount = 0 +asset_1_payment_amount = calculate_flash_swap_asset_1_payment_amount( + asset_1_reserves=pool.asset_1_reserves, + asset_2_reserves=pool.asset_2_reserves, + total_fee_share=pool.total_fee_share, + protocol_fee_ratio=pool.protocol_fee_ratio, + asset_1_loan_amount=asset_1_loan_amount, + asset_2_loan_amount=asset_2_loan_amount, + asset_2_payment_amount=asset_2_payment_amount, +) + +# Transfer amount is equal to sum of initial account balance and loan amount +# This transaction demonstrate that you can use the total amount +transfer_amount = balance + asset_1_loan_amount +transactions = [ + AssetTransferTxn( + sender=account["address"], + sp=suggested_params, + receiver=account["address"], + amt=transfer_amount, + index=pool.asset_1.id, + ) +] + + +if asset_1_payment_amount: + transactions.append( + AssetTransferTxn( + sender=account["address"], + sp=suggested_params, + receiver=pool.address, + index=pool.asset_1.id, + amt=asset_1_payment_amount, + ) + ) + +if asset_2_payment_amount: + if pool.asset_2.id: + transactions.append( + AssetTransferTxn( + sender=account["address"], + sp=suggested_params, + receiver=pool.address, + index=pool.asset_2.id, + amt=asset_2_payment_amount, + ) + ) + else: + transactions.append( + PaymentTxn( + sender=account["address"], + sp=suggested_params, + receiver=pool.address, + amt=asset_2_payment_amount, + ) + ) + +txn_group = prepare_flash_swap_transactions( + validator_app_id=pool.validator_app_id, + asset_1_id=pool.asset_1.id, + asset_2_id=pool.asset_2.id, + asset_1_loan_amount=asset_1_loan_amount, + asset_2_loan_amount=asset_2_loan_amount, + transactions=transactions, + suggested_params=suggested_params, + sender=account["address"], +) + +# Sign +txn_group.sign_with_private_key(account["address"], account["private_key"]) + +# Submit transactions to the network and wait for confirmation +txn_info = client.submit(txn_group, wait=True) +print("Transaction Info") +pprint(txn_info) + +print( + f"Check the transaction group on Algoexplorer: https://testnet.algoexplorer.io/tx/group/{quote_plus(txn_group.id)}" +) diff --git a/examples/v2/tutorial/__init__.py b/examples/v2/tutorial/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/v2/tutorial/common.py b/examples/v2/tutorial/common.py new file mode 100644 index 0000000..6ff0912 --- /dev/null +++ b/examples/v2/tutorial/common.py @@ -0,0 +1,67 @@ +import json +import os +import string +import random +from pprint import pprint + +from algosdk.future.transaction import AssetCreateTxn, wait_for_confirmation + + +def get_account_file_path(filename="account.json"): + dir_path = os.path.dirname(os.path.realpath(__file__)) + file_path = os.path.join(dir_path, filename) + return file_path + + +def get_account(filename="account.json"): + file_path = get_account_file_path(filename) + try: + with open(file_path, "r", encoding="utf-8") as f: + account = json.loads(f.read()) + except FileNotFoundError: + raise Exception("Please run generate_account.py to generate a test account.") + + return account + + +def get_assets_file_path(filename="assets.json"): + dir_path = os.path.dirname(os.path.realpath(__file__)) + file_path = os.path.join(dir_path, filename) + return file_path + + +def get_assets(filename="assets.json"): + file_path = get_account_file_path(filename) + try: + with open(file_path, "r", encoding="utf-8") as f: + assets = json.loads(f.read()) + except FileNotFoundError: + raise Exception("Please run generate_account.py to generate a test account.") + + return assets + + +def create_asset(algod, sender, private_key): + sp = algod.suggested_params() + asset_name = "".join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(8) + ) + max_total = 2**64 - 1 + txn = AssetCreateTxn( + sender=sender, + sp=sp, + total=max_total, + decimals=6, + default_frozen=False, + unit_name=asset_name, + asset_name=asset_name, + ) + signed_txn = txn.sign(private_key) + transaction_id = algod.send_transaction(signed_txn) + print(f"Asset Creation Transaction ID: {transaction_id}") + result = wait_for_confirmation(algod, transaction_id) + print("Asset Creation Result:") + pprint(result) + asset_id = result["asset-index"] + print(f"Created Asset ID: {asset_id}") + return asset_id diff --git a/examples/v2/utils.py b/examples/v2/utils.py new file mode 100644 index 0000000..caad47a --- /dev/null +++ b/examples/v2/utils.py @@ -0,0 +1,8 @@ +from algosdk.v2client.algod import AlgodClient + + +def get_algod(): + # return AlgodClient( + # "", "http://localhost:8080", headers={"User-Agent": "algosdk"} + # ) + return AlgodClient("", "https://testnet-api.algonode.network") diff --git a/setup.py b/setup.py index 9e606aa..8758e48 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ description="Tinyman Python SDK", author="Tinyman", author_email="hello@tinyman.org", - version="0.0.6", + version="2.0.0", long_description=long_description, long_description_content_type="text/markdown", license="MIT", @@ -18,7 +18,9 @@ }, install_requires=["py-algorand-sdk >= 1.6.0"], packages=setuptools.find_packages(), - python_requires=">=3.7", - package_data={'tinyman.v1': ['asc.json']}, + python_requires=">=3.8", + package_data={ + "tinyman.v1": ["asc.json"], + }, include_package_data=True, ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..f58854a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,8 @@ +from algosdk.future.transaction import SuggestedParams + + +def get_suggested_params(): + sp = SuggestedParams( + fee=1000, first=1, last=1000, min_fee=1000, flat_fee=True, gh="test" + ) + return sp diff --git a/tests/v2/__init__.py b/tests/v2/__init__.py new file mode 100644 index 0000000..538a59f --- /dev/null +++ b/tests/v2/__init__.py @@ -0,0 +1,46 @@ +from unittest import TestCase + +from algosdk.v2client.algod import AlgodClient + +from tinyman.v2.client import TinymanV2Client +from tests import get_suggested_params + + +class BaseTestCase(TestCase): + maxDiff = None + + @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, + user_address=user_address or cls.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 or cls.pool_token_asset_id, + "asset_1_protocol_fees": 0, + "asset_1_id": asset_1_id or cls.asset_1_id, + "asset_2_id": asset_2_id or cls.asset_2_id, + "issued_pool_tokens": 0, + "asset_2_reserves": 0, + "protocol_fee_ratio": 6, + "total_fee_share": 30, + } + state.update(**kwargs) + return state diff --git a/tests/v2/test_add_liquidity.py b/tests/v2/test_add_liquidity.py new file mode 100644 index 0000000..7b87df5 --- /dev/null +++ b/tests/v2/test_add_liquidity.py @@ -0,0 +1,315 @@ +from unittest.mock import ANY + +from algosdk.account import generate_account +from algosdk.constants import ASSETTRANSFER_TXN, APPCALL_TXN +from algosdk.encoding import decode_address +from algosdk.future.transaction import OnComplete +from algosdk.logic import get_application_address + +from tests.v2 import BaseTestCase +from tinyman.assets import AssetAmount +from tinyman.utils import int_to_bytes +from tinyman.v2.constants import ( + LOCKED_POOL_TOKENS, + ADD_INITIAL_LIQUIDITY_APP_ARGUMENT, + ADD_LIQUIDITY_APP_ARGUMENT, + ADD_LIQUIDITY_FLEXIBLE_MODE_APP_ARGUMENT, + ADD_LIQUIDITY_SINGLE_MODE_APP_ARGUMENT, +) +from tinyman.v2.contracts import get_pool_logicsig +from tinyman.v2.pools import Pool +from tinyman.v2.quotes import ( + InitialAddLiquidityQuote, + FlexibleAddLiquidityQuote, + SingleAssetAddLiquidityQuote, +) + + +class InitialAddLiquidityTestCase(BaseTestCase): + @classmethod + def setUpClass(cls): + cls.VALIDATOR_APP_ID = 12345 + cls.sender_private_key, cls.user_address = generate_account() + cls.asset_1_id = 10 + cls.asset_2_id = 8 + cls.pool_token_asset_id = 15 + cls.pool_address = get_pool_logicsig( + cls.VALIDATOR_APP_ID, cls.asset_1_id, cls.asset_2_id + ).address() + cls.application_address = get_application_address(cls.VALIDATOR_APP_ID) + cls.pool_state = cls.get_pool_state() + + cls.pool = Pool.from_state( + address=cls.pool_address, + state=cls.pool_state, + round_number=100, + client=cls.get_tinyman_client(), + ) + + def test_add_liquidity(self): + asset_a_amount = AssetAmount(self.pool.asset_1, 1_000_000) + asset_b_amount = AssetAmount(self.pool.asset_2, 100_000_000) + quote = self.pool.fetch_initial_add_liquidity_quote( + amount_a=asset_a_amount, amount_b=asset_b_amount, refresh=False + ) + + self.assertEqual(type(quote), InitialAddLiquidityQuote) + self.assertEqual( + quote.amounts_in[self.pool.asset_1], + AssetAmount(self.pool.asset_1, 1_000_000), + ) + self.assertEqual( + quote.amounts_in[self.pool.asset_2], + AssetAmount(self.pool.asset_2, 100_000_000), + ) + self.assertEqual( + quote.pool_token_asset_amount, + AssetAmount(self.pool.pool_token_asset, 10_000_000 - LOCKED_POOL_TOKENS), + ) + + suggested_params = self.get_suggested_params() + txn_group = self.pool.prepare_add_liquidity_transactions_from_quote( + quote=quote, suggested_params=suggested_params + ) + transactions = txn_group.transactions + + self.assertEqual(len(transactions), 3) + self.assertDictEqual( + dict(transactions[0].dictify()), + { + "aamt": quote.amounts_in[self.pool.asset_1].amount, + "arcv": decode_address(self.pool_address), + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": ASSETTRANSFER_TXN, + "xaid": self.pool.asset_1.id, + }, + ) + + self.assertDictEqual( + dict(transactions[1].dictify()), + { + "aamt": quote.amounts_in[self.pool.asset_2].amount, + "arcv": decode_address(self.pool_address), + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": ASSETTRANSFER_TXN, + "xaid": self.pool.asset_2.id, + }, + ) + + self.assertDictEqual( + dict(transactions[2].dictify()), + { + "apaa": [ADD_INITIAL_LIQUIDITY_APP_ARGUMENT], + "apan": OnComplete.NoOpOC, + "apas": [self.pool.pool_token_asset.id], + "apat": [decode_address(self.pool_address)], + "apid": self.VALIDATOR_APP_ID, + "fee": 2000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": APPCALL_TXN, + }, + ) + + +class AddLiquidityTestCase(BaseTestCase): + @classmethod + def setUpClass(cls): + cls.VALIDATOR_APP_ID = 12345 + cls.sender_private_key, cls.user_address = generate_account() + cls.asset_1_id = 10 + cls.asset_2_id = 8 + cls.pool_token_asset_id = 15 + cls.pool_address = get_pool_logicsig( + cls.VALIDATOR_APP_ID, cls.asset_1_id, cls.asset_2_id + ).address() + cls.application_address = get_application_address(cls.VALIDATOR_APP_ID) + cls.pool_state = cls.get_pool_state( + asset_1_reserves=1_000_000, + asset_2_reserves=100_000_000, + issued_pool_tokens=10_000_000, + ) + cls.pool = Pool.from_state( + address=cls.pool_address, + state=cls.pool_state, + round_number=100, + client=cls.get_tinyman_client(), + ) + + def test_flexible_add_liquidity(self): + asset_a_amount = AssetAmount(self.pool.asset_1, 10_000_000) + asset_b_amount = AssetAmount(self.pool.asset_2, 10_000_000) + quote = self.pool.fetch_flexible_add_liquidity_quote( + amount_a=asset_a_amount, amount_b=asset_b_amount, refresh=False + ) + + self.assertEqual(type(quote), FlexibleAddLiquidityQuote) + self.assertEqual(quote.slippage, 0.05) + self.assertEqual( + quote.amounts_in[self.pool.asset_1], + AssetAmount(self.pool.asset_1, 10_000_000), + ) + self.assertEqual( + quote.amounts_in[self.pool.asset_2], + AssetAmount(self.pool.asset_2, 10_000_000), + ) + self.assertEqual( + quote.pool_token_asset_amount, + AssetAmount(self.pool.pool_token_asset, 24_774_768), + ) + self.assertEqual(quote.min_pool_token_asset_amount_with_slippage, 23_536_029) + + internal_swap_quote = quote.internal_swap_quote + self.assertEqual( + internal_swap_quote.amount_in, AssetAmount(self.pool.asset_1, 2_168_784) + ) + self.assertEqual( + internal_swap_quote.amount_out, AssetAmount(self.pool.asset_2, 68_377_223) + ) + self.assertEqual( + internal_swap_quote.swap_fees, AssetAmount(self.pool.asset_1, 6_506) + ) + self.assertEqual(internal_swap_quote.price_impact, 0.68472) + + suggested_params = self.get_suggested_params() + txn_group = self.pool.prepare_add_liquidity_transactions_from_quote( + quote=quote, suggested_params=suggested_params + ) + transactions = txn_group.transactions + + self.assertEqual(len(transactions), 3) + self.assertDictEqual( + dict(transactions[0].dictify()), + { + "aamt": quote.amounts_in[self.pool.asset_1].amount, + "arcv": decode_address(self.pool_address), + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": ASSETTRANSFER_TXN, + "xaid": self.pool.asset_1.id, + }, + ) + self.assertDictEqual( + dict(transactions[1].dictify()), + { + "aamt": quote.amounts_in[self.pool.asset_2].amount, + "arcv": decode_address(self.pool_address), + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": ASSETTRANSFER_TXN, + "xaid": self.pool.asset_2.id, + }, + ) + self.assertDictEqual( + dict(transactions[2].dictify()), + { + "apaa": [ + ADD_LIQUIDITY_APP_ARGUMENT, + ADD_LIQUIDITY_FLEXIBLE_MODE_APP_ARGUMENT, + int_to_bytes(23_536_029), + ], + "apan": OnComplete.NoOpOC, + "apas": [self.pool.pool_token_asset.id], + "apat": [decode_address(self.pool.address)], + "apid": self.VALIDATOR_APP_ID, + "fee": 3000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": APPCALL_TXN, + }, + ) + + def test_single_asset_add_liquidity(self): + asset_a_amount = AssetAmount(self.pool.asset_1, 10_000_000) + quote = self.pool.fetch_single_asset_add_liquidity_quote( + amount_a=asset_a_amount, refresh=False + ) + + self.assertEqual(type(quote), SingleAssetAddLiquidityQuote) + self.assertEqual(quote.slippage, 0.05) + self.assertEqual(quote.amount_in, AssetAmount(self.pool.asset_1, 10_000_000)) + self.assertEqual( + quote.pool_token_asset_amount, + AssetAmount(self.pool.pool_token_asset, 23_155_740), + ) + self.assertEqual(quote.min_pool_token_asset_amount_with_slippage, 21_997_953) + + internal_swap_quote = quote.internal_swap_quote + self.assertEqual( + internal_swap_quote.amount_in, AssetAmount(self.pool.asset_1, 2_323_595) + ) + self.assertEqual( + internal_swap_quote.amount_out, AssetAmount(self.pool.asset_2, 69_848_864) + ) + self.assertEqual( + internal_swap_quote.swap_fees, AssetAmount(self.pool.asset_1, 6_970) + ) + self.assertEqual(internal_swap_quote.price_impact, 0.69939) + + suggested_params = self.get_suggested_params() + txn_group = self.pool.prepare_add_liquidity_transactions_from_quote( + quote=quote, suggested_params=suggested_params + ) + transactions = txn_group.transactions + + self.assertEqual(len(transactions), 2) + self.assertDictEqual( + dict(transactions[0].dictify()), + { + "aamt": quote.amount_in.amount, + "arcv": decode_address(self.pool_address), + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": ASSETTRANSFER_TXN, + "xaid": self.pool.asset_1.id, + }, + ) + self.assertDictEqual( + dict(transactions[1].dictify()), + { + "apaa": [ + ADD_LIQUIDITY_APP_ARGUMENT, + ADD_LIQUIDITY_SINGLE_MODE_APP_ARGUMENT, + int_to_bytes(21_997_953), + ], + "apan": OnComplete.NoOpOC, + "apas": [self.pool.pool_token_asset.id], + "apat": [decode_address(self.pool.address)], + "apid": self.VALIDATOR_APP_ID, + "fee": 3000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": APPCALL_TXN, + }, + ) diff --git a/tests/v2/test_bootstrap.py b/tests/v2/test_bootstrap.py new file mode 100644 index 0000000..8536fbc --- /dev/null +++ b/tests/v2/test_bootstrap.py @@ -0,0 +1,198 @@ +from unittest.mock import ANY + +from algosdk.account import generate_account +from algosdk.constants import APPCALL_TXN, PAYMENT_TXN +from algosdk.encoding import decode_address +from algosdk.future.transaction import OnComplete +from algosdk.logic import get_application_address + +from tests.v2 import BaseTestCase +from tinyman.v2.constants import BOOTSTRAP_APP_ARGUMENT +from tinyman.v2.contracts import get_pool_logicsig +from tinyman.v2.pools import Pool + + +class BootstrapTestCase(BaseTestCase): + @classmethod + def setUpClass(cls): + cls.VALIDATOR_APP_ID = 12345 + cls.application_address = get_application_address(cls.VALIDATOR_APP_ID) + + cls.sender_private_key, cls.user_address = generate_account() + cls.asset_1_id = 10 + cls.asset_2_id = 8 + cls.pool_address = get_pool_logicsig( + cls.VALIDATOR_APP_ID, cls.asset_1_id, cls.asset_2_id + ).address() + cls.application_address = get_application_address(cls.VALIDATOR_APP_ID) + cls.pool_state = {} + + cls.pool = Pool( + client=cls.get_tinyman_client(), + asset_a=cls.asset_1_id, + asset_b=cls.asset_2_id, + info=None, + fetch=False, + validator_app_id=cls.VALIDATOR_APP_ID, + ) + + def test_bootstrap(self): + txn_group = self.pool.prepare_bootstrap_transactions( + pool_algo_balance=0, + refresh=False, + suggested_params=self.get_suggested_params(), + ) + + transactions = txn_group.transactions + self.assertEqual(len(transactions), 2) + self.assertDictEqual( + dict(transactions[0].dictify()), + { + "amt": 1_049_000, + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "rcv": decode_address(self.pool_address), + "snd": decode_address(self.user_address), + "type": PAYMENT_TXN, + }, + ) + self.assertDictEqual( + dict(transactions[1].dictify()), + { + "apaa": [BOOTSTRAP_APP_ARGUMENT], + "apan": OnComplete.OptInOC, + "apas": [self.pool.asset_1.id, self.pool.asset_2.id], + "apid": self.VALIDATOR_APP_ID, + "fee": 7000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "rekey": decode_address(self.application_address), + "snd": decode_address(self.pool_address), + "type": APPCALL_TXN, + }, + ) + + def test_pool_is_already_funded(self): + txn_group = self.pool.prepare_bootstrap_transactions( + pool_algo_balance=2_000_000, + refresh=False, + suggested_params=self.get_suggested_params(), + ) + + transactions = txn_group.transactions + self.assertEqual(len(transactions), 1) + self.assertDictEqual( + dict(transactions[0].dictify()), + { + "apaa": [BOOTSTRAP_APP_ARGUMENT], + "apan": OnComplete.OptInOC, + "apas": [self.pool.asset_1.id, self.pool.asset_2.id], + "apid": self.VALIDATOR_APP_ID, + "fee": 7000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "rekey": decode_address(self.application_address), + "snd": decode_address(self.pool_address), + "type": APPCALL_TXN, + }, + ) + + +class BootstrapAlgoPoolTestCase(BaseTestCase): + @classmethod + def setUpClass(cls): + cls.VALIDATOR_APP_ID = 12345 + cls.application_address = get_application_address(cls.VALIDATOR_APP_ID) + + cls.sender_private_key, cls.user_address = generate_account() + cls.asset_1_id = 10 + cls.asset_2_id = 0 + cls.pool_address = get_pool_logicsig( + cls.VALIDATOR_APP_ID, cls.asset_1_id, cls.asset_2_id + ).address() + cls.application_address = get_application_address(cls.VALIDATOR_APP_ID) + cls.pool_state = {} + + cls.pool = Pool( + client=cls.get_tinyman_client(), + asset_a=cls.asset_1_id, + asset_b=cls.asset_2_id, + info=None, + fetch=False, + validator_app_id=cls.VALIDATOR_APP_ID, + ) + + def test_bootstrap(self): + txn_group = self.pool.prepare_bootstrap_transactions( + pool_algo_balance=0, + refresh=False, + suggested_params=self.get_suggested_params(), + ) + + transactions = txn_group.transactions + self.assertEqual(len(transactions), 2) + self.assertDictEqual( + dict(transactions[0].dictify()), + { + "amt": 948_000, + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "rcv": decode_address(self.pool_address), + "snd": decode_address(self.user_address), + "type": PAYMENT_TXN, + }, + ) + self.assertDictEqual( + dict(transactions[1].dictify()), + { + "apaa": [BOOTSTRAP_APP_ARGUMENT], + "apan": OnComplete.OptInOC, + "apas": [self.pool.asset_1.id, self.pool.asset_2.id], + "apid": self.VALIDATOR_APP_ID, + "fee": 6000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "rekey": decode_address(self.application_address), + "snd": decode_address(self.pool_address), + "type": APPCALL_TXN, + }, + ) + + def test_pool_is_already_funded(self): + txn_group = self.pool.prepare_bootstrap_transactions( + pool_algo_balance=2_000_000, + refresh=False, + suggested_params=self.get_suggested_params(), + ) + + transactions = txn_group.transactions + self.assertEqual(len(transactions), 1) + self.assertDictEqual( + dict(transactions[0].dictify()), + { + "apaa": [BOOTSTRAP_APP_ARGUMENT], + "apan": OnComplete.OptInOC, + "apas": [self.pool.asset_1.id, self.pool.asset_2.id], + "apid": self.VALIDATOR_APP_ID, + "fee": 6000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "rekey": decode_address(self.application_address), + "snd": decode_address(self.pool_address), + "type": APPCALL_TXN, + }, + ) diff --git a/tests/v2/test_flash_loan.py b/tests/v2/test_flash_loan.py new file mode 100644 index 0000000..4416032 --- /dev/null +++ b/tests/v2/test_flash_loan.py @@ -0,0 +1,138 @@ +from unittest.mock import ANY + +from algosdk.account import generate_account +from algosdk.constants import APPCALL_TXN +from algosdk.encoding import decode_address +from algosdk.future.transaction import AssetTransferTxn, OnComplete +from algosdk.logic import get_application_address + +from tests.v2 import BaseTestCase +from tinyman.assets import AssetAmount +from tinyman.utils import int_to_bytes +from tinyman.v2.constants import FLASH_LOAN_APP_ARGUMENT, VERIFY_FLASH_LOAN_APP_ARGUMENT +from tinyman.v2.contracts import get_pool_logicsig +from tinyman.v2.pools import Pool +from tinyman.v2.quotes import FlashLoanQuote + + +class FlashLoanTestCase(BaseTestCase): + @classmethod + def setUpClass(cls): + cls.VALIDATOR_APP_ID = 12345 + cls.sender_private_key, cls.user_address = generate_account() + cls.asset_1_id = 10 + cls.asset_2_id = 8 + cls.pool_token_asset_id = 15 + cls.pool_address = get_pool_logicsig( + cls.VALIDATOR_APP_ID, cls.asset_1_id, cls.asset_2_id + ).address() + cls.application_address = get_application_address(cls.VALIDATOR_APP_ID) + cls.pool_state = cls.get_pool_state( + asset_1_reserves=10_000_000, + asset_2_reserves=1_000_000_000, + issued_pool_tokens=100_000_000, + ) + cls.pool = Pool.from_state( + address=cls.pool_address, + state=cls.pool_state, + round_number=100, + client=cls.get_tinyman_client(), + ) + + def test_flash_loan(self): + quote = self.pool.fetch_flash_loan_quote( + loan_amount_a=AssetAmount(self.pool.asset_1, 1_000_000), + loan_amount_b=AssetAmount(self.pool.asset_2, 10_000_000), + refresh=False, + ) + + self.assertEqual(type(quote), FlashLoanQuote) + self.assertEqual( + quote.amounts_out[self.pool.asset_1], + AssetAmount(self.pool.asset_1, 1_000_000), + ) + self.assertEqual( + quote.amounts_out[self.pool.asset_2], + AssetAmount(self.pool.asset_2, 10_000_000), + ) + self.assertEqual( + quote.amounts_in[self.pool.asset_1], + AssetAmount(self.pool.asset_1, 1_003_000), + ) + self.assertEqual( + quote.amounts_in[self.pool.asset_2], + AssetAmount(self.pool.asset_2, 10_030_000), + ) + self.assertEqual( + quote.fees[self.pool.asset_1], AssetAmount(self.pool.asset_1, 3_000) + ) + self.assertEqual( + quote.fees[self.pool.asset_2], AssetAmount(self.pool.asset_2, 30_000) + ) + + suggested_params = self.get_suggested_params() + txn_group = self.pool.prepare_flash_loan_transactions_from_quote( + quote=quote, suggested_params=suggested_params, transactions=[] + ) + index_diff = 3 + transactions = txn_group.transactions + + self.assertEqual(len(transactions), 4) + self.assertDictEqual( + dict(transactions[0].dictify()), + { + "apaa": [ + FLASH_LOAN_APP_ARGUMENT, + int_to_bytes(index_diff), + int_to_bytes(quote.amounts_out[self.pool.asset_1].amount), + int_to_bytes(quote.amounts_out[self.pool.asset_2].amount), + ], + "apan": OnComplete.NoOpOC, + "apas": [self.pool.asset_1.id, self.pool.asset_2.id], + "apat": [decode_address(self.pool.address)], + "apid": self.VALIDATOR_APP_ID, + "fee": 3000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": APPCALL_TXN, + }, + ) + + self.assertEqual(type(transactions[1]), AssetTransferTxn) + self.assertEqual(transactions[1].index, self.pool.asset_1.id) + self.assertEqual(transactions[1].sender, self.user_address) + self.assertEqual(transactions[1].receiver, self.pool_address) + self.assertEqual( + transactions[1].amount, quote.amounts_in[self.pool.asset_1].amount + ) + + self.assertEqual(type(transactions[2]), AssetTransferTxn) + self.assertEqual(transactions[2].index, self.pool.asset_2.id) + self.assertEqual(transactions[2].sender, self.user_address) + self.assertEqual(transactions[2].receiver, self.pool_address) + self.assertEqual( + transactions[2].amount, quote.amounts_in[self.pool.asset_2].amount + ) + + self.assertDictEqual( + dict(transactions[3].dictify()), + { + "apaa": [ + VERIFY_FLASH_LOAN_APP_ARGUMENT, + int_to_bytes(index_diff), + ], + "apan": OnComplete.NoOpOC, + "apat": [decode_address(self.pool.address)], + "apid": self.VALIDATOR_APP_ID, + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": APPCALL_TXN, + }, + ) diff --git a/tests/v2/test_flash_swap.py b/tests/v2/test_flash_swap.py new file mode 100644 index 0000000..ed7b6d1 --- /dev/null +++ b/tests/v2/test_flash_swap.py @@ -0,0 +1,99 @@ +from unittest.mock import ANY + +from algosdk.account import generate_account +from algosdk.constants import APPCALL_TXN +from algosdk.encoding import decode_address +from algosdk.future.transaction import OnComplete +from algosdk.logic import get_application_address + +from tests.v2 import BaseTestCase +from tinyman.utils import int_to_bytes +from tinyman.v2.constants import FLASH_SWAP_APP_ARGUMENT, VERIFY_FLASH_SWAP_APP_ARGUMENT +from tinyman.v2.contracts import get_pool_logicsig +from tinyman.v2.flash_swap import prepare_flash_swap_transactions +from tinyman.v2.pools import Pool + + +class FlashSwapTestCase(BaseTestCase): + @classmethod + def setUpClass(cls): + cls.VALIDATOR_APP_ID = 12345 + cls.sender_private_key, cls.user_address = generate_account() + cls.asset_1_id = 10 + cls.asset_2_id = 8 + cls.pool_token_asset_id = 15 + cls.pool_address = get_pool_logicsig( + cls.VALIDATOR_APP_ID, cls.asset_1_id, cls.asset_2_id + ).address() + cls.application_address = get_application_address(cls.VALIDATOR_APP_ID) + cls.pool_state = cls.get_pool_state( + asset_1_reserves=10_000_000, + asset_2_reserves=1_000_000_000, + issued_pool_tokens=100_000_000, + ) + cls.pool = Pool.from_state( + address=cls.pool_address, + state=cls.pool_state, + round_number=100, + client=cls.get_tinyman_client(), + ) + + def test_flash_swap(self): + index_diff = 1 + txn_group = prepare_flash_swap_transactions( + validator_app_id=self.VALIDATOR_APP_ID, + asset_1_id=self.pool.asset_1.id, + asset_2_id=self.pool.asset_2.id, + asset_1_loan_amount=1_000_000, + asset_2_loan_amount=100_000_000, + transactions=[], + sender=self.user_address, + suggested_params=self.get_suggested_params(), + ) + + transactions = txn_group.transactions + self.assertEqual(len(transactions), 2) + self.assertDictEqual( + dict(transactions[0].dictify()), + { + "apaa": [ + FLASH_SWAP_APP_ARGUMENT, + int_to_bytes(index_diff), + int_to_bytes(1_000_000), + int_to_bytes(100_000_000), + ], + "apan": OnComplete.NoOpOC, + "apas": [self.pool.asset_1.id, self.pool.asset_2.id], + "apat": [decode_address(self.pool.address)], + "apid": self.VALIDATOR_APP_ID, + "fee": 3000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": APPCALL_TXN, + }, + ) + + # Verify + self.assertDictEqual( + dict(transactions[1].dictify()), + { + "apaa": [ + VERIFY_FLASH_SWAP_APP_ARGUMENT, + int_to_bytes(index_diff), + ], + "apan": OnComplete.NoOpOC, + "apas": [self.pool.asset_1.id, self.pool.asset_2.id], + "apat": [decode_address(self.pool.address)], + "apid": self.VALIDATOR_APP_ID, + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": APPCALL_TXN, + }, + ) diff --git a/tests/v2/test_remove_liquidity.py b/tests/v2/test_remove_liquidity.py new file mode 100644 index 0000000..05881bc --- /dev/null +++ b/tests/v2/test_remove_liquidity.py @@ -0,0 +1,188 @@ +from unittest.mock import ANY + +from algosdk.account import generate_account +from algosdk.constants import ASSETTRANSFER_TXN, APPCALL_TXN +from algosdk.encoding import decode_address +from algosdk.future.transaction import OnComplete +from algosdk.logic import get_application_address + +from tests.v2 import BaseTestCase +from tinyman.assets import AssetAmount +from tinyman.utils import int_to_bytes +from tinyman.v2.constants import REMOVE_LIQUIDITY_APP_ARGUMENT +from tinyman.v2.contracts import get_pool_logicsig +from tinyman.v2.pools import Pool +from tinyman.v2.quotes import RemoveLiquidityQuote, SingleAssetRemoveLiquidityQuote + + +class RemoveLiquidityTestCase(BaseTestCase): + @classmethod + def setUpClass(cls): + cls.VALIDATOR_APP_ID = 12345 + cls.sender_private_key, cls.user_address = generate_account() + cls.asset_1_id = 10 + cls.asset_2_id = 8 + cls.pool_token_asset_id = 15 + cls.pool_address = get_pool_logicsig( + cls.VALIDATOR_APP_ID, cls.asset_1_id, cls.asset_2_id + ).address() + cls.application_address = get_application_address(cls.VALIDATOR_APP_ID) + cls.pool_state = cls.get_pool_state( + asset_1_reserves=1_000_000, + asset_2_reserves=100_000_000, + issued_pool_tokens=10_000_000, + ) + cls.pool = Pool.from_state( + address=cls.pool_address, + state=cls.pool_state, + round_number=100, + client=cls.get_tinyman_client(), + ) + + def test_remove_liquidity(self): + quote = self.pool.fetch_remove_liquidity_quote( + pool_token_asset_in=5_000_000, refresh=False + ) + + self.assertEqual(type(quote), RemoveLiquidityQuote) + self.assertEqual(quote.slippage, 0.05) + self.assertEqual( + quote.pool_token_asset_amount, + AssetAmount(self.pool.pool_token_asset, 5_000_000), + ) + self.assertEqual( + quote.amounts_out[self.pool.asset_1], + AssetAmount(self.pool.asset_1, 500_000), + ) + self.assertEqual( + quote.amounts_out[self.pool.asset_2], + AssetAmount(self.pool.asset_2, 50_000_000), + ) + self.assertEqual( + quote.amounts_out_with_slippage[self.pool.asset_1], + AssetAmount(self.pool.asset_1, 475_000), + ) + self.assertEqual( + quote.amounts_out_with_slippage[self.pool.asset_2], + AssetAmount(self.pool.asset_2, 47_500_000), + ) + + suggested_params = self.get_suggested_params() + txn_group = self.pool.prepare_remove_liquidity_transactions_from_quote( + quote=quote, suggested_params=suggested_params + ) + transactions = txn_group.transactions + + self.assertEqual(len(transactions), 2) + self.assertDictEqual( + dict(transactions[0].dictify()), + { + "aamt": quote.pool_token_asset_amount.amount, + "arcv": decode_address(self.pool_address), + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": ASSETTRANSFER_TXN, + "xaid": self.pool.pool_token_asset.id, + }, + ) + self.assertDictEqual( + dict(transactions[1].dictify()), + { + "apaa": [ + REMOVE_LIQUIDITY_APP_ARGUMENT, + int_to_bytes( + quote.amounts_out_with_slippage[self.pool.asset_1].amount + ), + int_to_bytes( + quote.amounts_out_with_slippage[self.pool.asset_2].amount + ), + ], + "apan": OnComplete.NoOpOC, + "apas": [self.pool.asset_1.id, self.pool.asset_2.id], + "apat": [decode_address(self.pool_address)], + "apid": self.VALIDATOR_APP_ID, + "fee": 3000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": APPCALL_TXN, + }, + ) + + def test_single_asset_remove_liquidity(self): + quote = self.pool.fetch_single_asset_remove_liquidity_quote( + pool_token_asset_in=5_000_000, output_asset=self.pool.asset_1, refresh=False + ) + + self.assertEqual(type(quote), SingleAssetRemoveLiquidityQuote) + self.assertEqual(quote.slippage, 0.05) + self.assertEqual( + quote.pool_token_asset_amount, + AssetAmount(self.pool.pool_token_asset, 5_000_000), + ) + self.assertEqual(quote.amount_out, AssetAmount(self.pool.asset_1, 749_624)) + self.assertEqual( + quote.amount_out_with_slippage, AssetAmount(self.pool.asset_1, 712_143) + ) + + internal_swap_quote = quote.internal_swap_quote + self.assertEqual( + internal_swap_quote.amount_in, AssetAmount(self.pool.asset_2, 50_000_000) + ) + self.assertEqual( + internal_swap_quote.amount_out, AssetAmount(self.pool.asset_1, 249_624) + ) + self.assertEqual( + internal_swap_quote.swap_fees, AssetAmount(self.pool.asset_2, 150_000) + ) + self.assertEqual(internal_swap_quote.price_impact, 0.50075) + + suggested_params = self.get_suggested_params() + txn_group = self.pool.prepare_remove_liquidity_transactions_from_quote( + quote=quote, suggested_params=suggested_params + ) + transactions = txn_group.transactions + + self.assertEqual(len(transactions), 2) + self.assertDictEqual( + dict(transactions[0].dictify()), + { + "aamt": quote.pool_token_asset_amount.amount, + "arcv": decode_address(self.pool_address), + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": ASSETTRANSFER_TXN, + "xaid": self.pool.pool_token_asset.id, + }, + ) + self.assertDictEqual( + dict(transactions[1].dictify()), + { + "apaa": [ + REMOVE_LIQUIDITY_APP_ARGUMENT, + int_to_bytes(quote.amount_out_with_slippage.amount), + int_to_bytes(0), + ], + "apan": OnComplete.NoOpOC, + "apas": [self.pool.asset_1.id], + "apat": [decode_address(self.pool_address)], + "apid": self.VALIDATOR_APP_ID, + "fee": 3000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": APPCALL_TXN, + }, + ) diff --git a/tests/v2/test_swap.py b/tests/v2/test_swap.py new file mode 100644 index 0000000..96decfd --- /dev/null +++ b/tests/v2/test_swap.py @@ -0,0 +1,170 @@ +from unittest.mock import ANY + +from algosdk.account import generate_account +from algosdk.constants import ASSETTRANSFER_TXN, APPCALL_TXN +from algosdk.encoding import decode_address +from algosdk.future.transaction import OnComplete +from algosdk.logic import get_application_address + +from tests.v2 import BaseTestCase +from tinyman.assets import AssetAmount +from tinyman.utils import int_to_bytes +from tinyman.v2.constants import ( + SWAP_APP_ARGUMENT, + FIXED_INPUT_APP_ARGUMENT, + FIXED_OUTPUT_APP_ARGUMENT, +) +from tinyman.v2.contracts import get_pool_logicsig +from tinyman.v2.pools import Pool +from tinyman.v2.quotes import SwapQuote + + +class SwapTestCase(BaseTestCase): + @classmethod + def setUpClass(cls): + cls.VALIDATOR_APP_ID = 12345 + cls.sender_private_key, cls.user_address = generate_account() + cls.asset_1_id = 10 + cls.asset_2_id = 8 + cls.pool_token_asset_id = 15 + cls.pool_address = get_pool_logicsig( + cls.VALIDATOR_APP_ID, cls.asset_1_id, cls.asset_2_id + ).address() + cls.application_address = get_application_address(cls.VALIDATOR_APP_ID) + cls.pool_state = cls.get_pool_state( + asset_1_reserves=10_000_000, + asset_2_reserves=1_000_000_000, + issued_pool_tokens=100_000_000, + ) + cls.pool = Pool.from_state( + address=cls.pool_address, + state=cls.pool_state, + round_number=100, + client=cls.get_tinyman_client(), + ) + + def test_fixed_input_swap(self): + quote = self.pool.fetch_fixed_input_swap_quote( + amount_in=AssetAmount(self.pool.asset_1, 10_000_000), refresh=False + ) + + self.assertEqual(type(quote), SwapQuote) + self.assertEqual(quote.slippage, 0.05) + self.assertEqual(quote.swap_type, "fixed-input") + self.assertEqual(quote.amount_in, AssetAmount(self.pool.asset_1, 10_000_000)) + self.assertEqual(quote.amount_out, AssetAmount(self.pool.asset_2, 499_248_873)) + self.assertEqual( + quote.amount_in_with_slippage, AssetAmount(self.pool.asset_1, 10_000_000) + ) + self.assertEqual( + quote.amount_out_with_slippage, AssetAmount(self.pool.asset_2, 474_286_430) + ) + self.assertEqual(quote.swap_fees, AssetAmount(self.pool.asset_1, 300_00)) + self.assertEqual(quote.price_impact, 0.50075) + + suggested_params = self.get_suggested_params() + txn_group = self.pool.prepare_swap_transactions_from_quote( + quote=quote, suggested_params=suggested_params + ) + transactions = txn_group.transactions + + self.assertEqual(len(transactions), 2) + self.assertDictEqual( + dict(transactions[0].dictify()), + { + "aamt": quote.amount_in.amount, + "arcv": decode_address(self.pool_address), + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": ASSETTRANSFER_TXN, + "xaid": quote.amount_in.asset.id, + }, + ) + self.assertDictEqual( + dict(transactions[1].dictify()), + { + "apaa": [ + SWAP_APP_ARGUMENT, + FIXED_INPUT_APP_ARGUMENT, + int_to_bytes(quote.amount_out_with_slippage.amount), + ], + "apan": OnComplete.NoOpOC, + "apas": [self.pool.asset_1.id, self.pool.asset_2.id], + "apat": [decode_address(self.pool_address)], + "apid": self.VALIDATOR_APP_ID, + "fee": 2000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": APPCALL_TXN, + }, + ) + + def test_fixed_output_swap(self): + quote = self.pool.fetch_fixed_output_swap_quote( + amount_out=AssetAmount(self.pool.asset_2, 499_248_873), refresh=False + ) + + self.assertEqual(type(quote), SwapQuote) + self.assertEqual(quote.slippage, 0.05) + self.assertEqual(quote.swap_type, "fixed-output") + self.assertEqual(quote.amount_in, AssetAmount(self.pool.asset_1, 10_000_000)) + self.assertEqual(quote.amount_out, AssetAmount(self.pool.asset_2, 499_248_873)) + self.assertEqual( + quote.amount_in_with_slippage, AssetAmount(self.pool.asset_1, 10_500_000) + ) + self.assertEqual( + quote.amount_out_with_slippage, AssetAmount(self.pool.asset_2, 499_248_873) + ) + self.assertEqual(quote.swap_fees, AssetAmount(self.pool.asset_1, 300_00)) + self.assertEqual(quote.price_impact, 0.50075) + + suggested_params = self.get_suggested_params() + txn_group = self.pool.prepare_swap_transactions_from_quote( + quote=quote, suggested_params=suggested_params + ) + transactions = txn_group.transactions + + self.assertEqual(len(transactions), 2) + self.assertDictEqual( + dict(transactions[0].dictify()), + { + "aamt": quote.amount_in_with_slippage.amount, + "arcv": decode_address(self.pool_address), + "fee": 1000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": ASSETTRANSFER_TXN, + "xaid": quote.amount_in.asset.id, + }, + ) + self.assertDictEqual( + dict(transactions[1].dictify()), + { + "apaa": [ + SWAP_APP_ARGUMENT, + FIXED_OUTPUT_APP_ARGUMENT, + int_to_bytes(quote.amount_out.amount), + ], + "apan": OnComplete.NoOpOC, + "apas": [self.pool.asset_1.id, self.pool.asset_2.id], + "apat": [decode_address(self.pool_address)], + "apid": self.VALIDATOR_APP_ID, + "fee": 3000, + "fv": ANY, + "gh": ANY, + "grp": ANY, + "lv": ANY, + "snd": decode_address(self.user_address), + "type": APPCALL_TXN, + }, + ) diff --git a/tinyman/assets.py b/tinyman/assets.py index f2ec296..f284dc2 100644 --- a/tinyman/assets.py +++ b/tinyman/assets.py @@ -2,6 +2,7 @@ from decimal import Decimal from typing import Optional + @dataclass class Asset: id: int @@ -11,27 +12,30 @@ class Asset: def __call__(self, amount: int) -> "AssetAmount": return AssetAmount(self, amount) - + def __hash__(self) -> int: return self.id + def __repr__(self) -> str: + return f"Asset({self.unit_name} - {self.id})" + + def __eq__(self, other) -> bool: + return self.id == other.id + def fetch(self, algod): if self.id > 0: - params = algod.asset_info(self.id)['params'] + params = algod.asset_info(self.id)["params"] else: params = { - 'name': 'Algo', - 'unit-name': 'ALGO', - 'decimals': 6, + "name": "Algo", + "unit-name": "ALGO", + "decimals": 6, } - self.name = params.get('name') - self.unit_name = params.get('unit-name') - self.decimals = params['decimals'] + self.name = params.get("name") + self.unit_name = params.get("unit-name") + self.decimals = params["decimals"] return self - def __repr__(self) -> str: - return f'Asset({self.unit_name} - {self.id})' - @dataclass class AssetAmount: @@ -41,39 +45,44 @@ class AssetAmount: def __mul__(self, other: float): if isinstance(other, (float, int)): return AssetAmount(self.asset, int(self.amount * other)) - raise TypeError('Unsupported types for *') + raise TypeError("Unsupported types for *") def __add__(self, other: "AssetAmount"): if isinstance(other, AssetAmount) and other.asset == self.asset: return AssetAmount(self.asset, int(self.amount + other.amount)) - raise TypeError('Unsupported types for +') + raise TypeError("Unsupported types for +") def __sub__(self, other: "AssetAmount"): if isinstance(other, AssetAmount) and other.asset == self.asset: return AssetAmount(self.asset, int(self.amount - other.amount)) - raise TypeError('Unsupported types for -') + raise TypeError("Unsupported types for -") def __gt__(self, other: "AssetAmount"): if isinstance(other, AssetAmount) and other.asset == self.asset: return self.amount > other.amount if isinstance(other, (float, int)): return self.amount > other - raise TypeError('Unsupported types for >') + raise TypeError("Unsupported types for >") def __lt__(self, other: "AssetAmount"): if isinstance(other, AssetAmount) and other.asset == self.asset: return self.amount < other.amount if isinstance(other, (float, int)): return self.amount < other - raise TypeError('Unsupported types for <') + raise TypeError("Unsupported types for <") def __eq__(self, other: "AssetAmount"): if isinstance(other, AssetAmount) and other.asset == self.asset: return self.amount == other.amount if isinstance(other, (float, int)): return self.amount == other - raise TypeError('Unsupported types for ==') + raise TypeError("Unsupported types for ==") def __repr__(self) -> str: - amount = Decimal(self.amount) / Decimal(10**self.asset.decimals) - return f'{self.asset.unit_name}(\'{amount}\')' + if self.asset.decimals is not None: + amount = ( + Decimal(self.amount) / Decimal(10**self.asset.decimals) + ).quantize(1 / Decimal(10**self.asset.decimals)) + return f"{self.asset.unit_name}('{amount}' Base Unit)" + else: + return f"{self.asset.unit_name}('{self.amount}' Micro Unit)" diff --git a/tinyman/client.py b/tinyman/client.py new file mode 100644 index 0000000..296f51d --- /dev/null +++ b/tinyman/client.py @@ -0,0 +1,74 @@ +from typing import Optional + +from algosdk.error import AlgodHTTPError +from algosdk.future.transaction import wait_for_confirmation +from algosdk.v2client.algod import AlgodClient + +from tinyman.assets import Asset +from tinyman.optin import prepare_asset_optin_transactions + + +class BaseTinymanClient: + def __init__( + self, + algod_client: AlgodClient, + validator_app_id: int, + user_address=None, + staking_app_id: Optional[int] = None, + ): + self.algod = algod_client + self.validator_app_id = validator_app_id + self.staking_app_id = staking_app_id + self.assets_cache = {} + self.user_address = user_address + + def fetch_pool(self, *args, **kwargs): + raise NotImplementedError() + + def fetch_asset(self, asset_id): + if asset_id not in self.assets_cache: + asset = Asset(asset_id) + asset.fetch(self.algod) + self.assets_cache[asset_id] = asset + return self.assets_cache[asset_id] + + def submit(self, transaction_group, wait=False): + try: + txid = self.algod.send_transactions(transaction_group.signed_transactions) + except AlgodHTTPError as e: + raise Exception(str(e)) + + if wait: + txn_info = wait_for_confirmation(self.algod, txid) + txn_info["txid"] = txid + return txn_info + return {"txid": txid} + + 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 is_opted_in(self, user_address=None): + user_address = user_address or self.user_address + account_info = self.algod.account_info(user_address) + for a in account_info.get("apps-local-state", []): + if a["id"] == self.validator_app_id: + return True + return False + + def asset_is_opted_in(self, asset_id, user_address=None): + user_address = user_address or self.user_address + 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 diff --git a/tinyman/v1/optin.py b/tinyman/optin.py similarity index 89% rename from tinyman/v1/optin.py rename to tinyman/optin.py index bfdc0cc..30c409b 100644 --- a/tinyman/v1/optin.py +++ b/tinyman/optin.py @@ -1,7 +1,4 @@ -import base64 -import algosdk from algosdk.future.transaction import ApplicationOptInTxn, AssetOptInTxn -from algosdk.v2client.algod import AlgodClient from tinyman.utils import TransactionGroup @@ -17,6 +14,8 @@ def prepare_app_optin_transactions(validator_app_id, sender, suggested_params): def prepare_asset_optin_transactions(asset_id, sender, suggested_params): + assert asset_id != 0, "Cannot opt into ALGO" + txn = AssetOptInTxn( sender=sender, sp=suggested_params, diff --git a/tinyman/staking/__init__.py b/tinyman/staking/__init__.py new file mode 100644 index 0000000..11191c3 --- /dev/null +++ b/tinyman/staking/__init__.py @@ -0,0 +1,572 @@ +import json +import re +from base64 import b64decode, b64encode +from datetime import datetime +from hashlib import sha256 +from typing import Optional + +from algosdk.constants import PAYMENT_TXN, ASSETTRANSFER_TXN +from algosdk.encoding import is_valid_address +from algosdk.future.transaction import ( + ApplicationClearStateTxn, + ApplicationOptInTxn, + PaymentTxn, + ApplicationNoOpTxn, + AssetTransferTxn, +) + +from tinyman.utils import ( + TransactionGroup, + apply_delta, + bytes_to_int_list, + int_list_to_bytes, + int_to_bytes, + timestamp_to_date_str, +) +from tinyman.staking.constants import DATE_FORMAT + + +def prepare_commit_transaction( + app_id: int, + program_id: int, + program_account: str, + pool_asset_id: int, + amount: int, + sender: str, + suggested_params, + required_asset_id: Optional[int] = None, +): + commitment_txn = ApplicationNoOpTxn( + index=app_id, + sender=sender, + sp=suggested_params, + app_args=["commit", int_to_bytes(amount)], + foreign_assets=[pool_asset_id], + accounts=[program_account], + note=b"tinymanStaking/v1:b" + + int_to_bytes(program_id) + + int_to_bytes(pool_asset_id) + + int_to_bytes(amount), + ) + transactions = [commitment_txn] + + if required_asset_id is not None: + nft_log_balance_txn = ApplicationNoOpTxn( + index=app_id, + sender=sender, + sp=suggested_params, + app_args=["log_balance"], + foreign_assets=[required_asset_id], + ) + transactions.append(nft_log_balance_txn) + + return TransactionGroup(transactions) + + +def parse_commit_transaction(txn, app_id: int): + if txn.get("application-transaction"): + app_call = txn["application-transaction"] + if app_call["on-completion"] != "noop": + return + if app_call["application-id"] != app_id: + return + if app_call["application-args"][0] == b64encode(b"commit").decode(): + result = {} + try: + note = txn["note"] + result["pooler"] = txn["sender"] + result["program_address"] = app_call["accounts"][0] + result["pool_asset_id"] = app_call["foreign-assets"][0] + result["program_id"] = int.from_bytes( + b64decode(note)[19 : 19 + 8], "big" + ) + result["amount"] = int.from_bytes( + b64decode(app_call["application-args"][1]), "big" + ) + result["balance"] = int.from_bytes(b64decode(txn["logs"][0])[8:], "big") + result["round"] = txn["confirmed-round"] + return result + except Exception: + return + return + + +def parse_log_balance_transaction(txn, app_id: int): + if txn.get("application-transaction"): + app_call = txn["application-transaction"] + if app_call["on-completion"] != "noop": + return + if app_call["application-id"] != app_id: + return + if app_call["application-args"][0] == b64encode(b"log_balance").decode(): + result = {} + try: + result["pooler"] = txn["sender"] + result["asset_id"] = app_call["foreign-assets"][0] + result["balance"] = int.from_bytes(b64decode(txn["logs"][0])[8:], "big") + result["round"] = txn["confirmed-round"] + return result + except Exception: + return + return + + +def parse_program_config_transaction(txn, app_id: int): + if txn.get("application-transaction"): + app_call = txn["application-transaction"] + if app_call["application-id"] != app_id: + return + if app_call["on-completion"] == "clear": + return ("clear", None) + arg1 = b64decode(app_call["application-args"][0]).decode() + local_delta = txn["local-state-delta"][0]["delta"] + return (arg1, local_delta) + return + + +def parse_program_update_transaction(txn, app_id: int): + if txn.get("application-transaction"): + app_call = txn["application-transaction"] + if app_call["on-completion"] != "noop": + return + if app_call["application-id"] != app_id: + return + if app_call["application-args"][0] == b64encode(b"update").decode(): + try: + local_delta = txn["local-state-delta"][0]["delta"] + state = apply_delta({}, local_delta) + result = parse_program_state(txn["sender"], state) + return result + except Exception: + return + return + + +def parse_program_state(address, state): + result = {} + result["address"] = address + result["id"] = state[b"id"] + result["url"] = state[b"url"] + result["reward_asset_id"] = state[b"reward_asset_id"] + result["reward_period"] = state[b"reward_period"] + result["start_date"] = timestamp_to_date_str(state[b"start_time"]) + result["end_date"] = timestamp_to_date_str(state[b"end_time"]) + result["pools"] = [] + asset_ids = bytes_to_int_list(state[b"assets"]) + result["asset_ids"] = asset_ids + mins = bytes_to_int_list(state[b"mins"]) + result["mins"] = mins + empty_rewards_bytes = int_list_to_bytes([0] * 15) + rewards = [] + for i in range(1, 8): + r = bytes_to_int_list(state.get(f"r{i}".encode(), empty_rewards_bytes)) + start = r[0] + amounts = r[1:] + if start: + rewards.append( + {"start_date": timestamp_to_date_str(start), "amounts": amounts} + ) + result["reward_amounts_dict"] = rewards + for i in range(len(asset_ids)): + if asset_ids[i] > 0: + result["pools"].append( + { + "asset_id": asset_ids[i], + "min_amount": mins[i], + "reward_amounts": { + x["start_date"]: x["amounts"][i] for x in rewards + }, + } + ) + return result + + +def prepare_setup_transaction( + app_id: int, + url: str, + reward_asset_id: int, + reward_period: int, + start_time: int, + end_time: int, + asset_ids: "list[int]", + min_amounts: "list[int]", + sender, + suggested_params, +): + assets = [0] * 14 + mins = [0] * 14 + for i in range(len(asset_ids)): + assets[i] = asset_ids[i] + mins[i] = min_amounts[i] + txn = ApplicationOptInTxn( + index=app_id, + sender=sender, + sp=suggested_params, + # setup, url, reward_asset_id, reward_period, start_time, end_time, int[14]{asset_id_1, asset_id_2, ...} + app_args=[ + "setup", + url, + int_to_bytes(reward_asset_id), + int_to_bytes(reward_period), + int_to_bytes(start_time), + int_to_bytes(end_time), + int_list_to_bytes(assets), + int_list_to_bytes(mins), + ], + foreign_assets=[reward_asset_id], + ) + return TransactionGroup([txn]) + + +def prepare_clear_state_transaction(app_id, sender, suggested_params): + clear_txn = ApplicationClearStateTxn( + index=app_id, sender=sender, sp=suggested_params + ) + return TransactionGroup([clear_txn]) + + +def prepare_update_rewards_transaction( + app_id: int, reward_amounts_dict: dict, sender, suggested_params +): + r = [ + [0] * 15, + [0] * 15, + [0] * 15, + [0] * 15, + [0] * 15, + ] + for i, start_time in enumerate(sorted(reward_amounts_dict.keys())): + amounts = reward_amounts_dict[start_time] + r[i][0] = start_time + for j, x in enumerate(amounts): + r[i][j + 1] = x + + txn = ApplicationNoOpTxn( + index=app_id, + sender=sender, + sp=suggested_params, + # ("update_rewards", int[15]{rewards_first_valid_time, rewards_asset_1, rewards_asset_2, ...}, int[15]{rewards_first_valid_time, rewards_asset_1, rewards_asset_2, ...}, ...) + app_args=[ + "update_rewards", + int_list_to_bytes(r[0]), + int_list_to_bytes(r[1]), + int_list_to_bytes(r[2]), + int_list_to_bytes(r[3]), + int_list_to_bytes(r[4]), + ], + ) + return TransactionGroup([txn]) + + +def prepare_end_program_transaction( + app_id: int, end_time: int, sender, suggested_params +): + txn = ApplicationNoOpTxn( + index=app_id, + sender=sender, + sp=suggested_params, + app_args=[ + "end_program", + int_to_bytes(end_time), + ], + ) + return TransactionGroup([txn]) + + +def prepare_payment_transaction( + staker_address: str, + reward_asset_id: int, + amount: int, + metadata: dict, + sender, + suggested_params, +): + note = generate_note_from_metadata(metadata) + # Compose a lease key from the distribution key (date, pool_address) and staker_address + # This is to prevent accidentally submitting multiple payments for the same staker for the same cycles + # Note: the lease is only ensured unique between first_valid & last_valid + lease_data = json.dumps( + [metadata["rewards"]["distribution"], staker_address] + ).encode() + lease = sha256(lease_data).digest() + if reward_asset_id == 0: + txn = PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=staker_address, + amt=amount, + note=note, + lease=lease, + ) + return txn + else: + txn = AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=staker_address, + index=reward_asset_id, + amt=amount, + note=note, + lease=lease, + ) + return txn + + +def prepare_reward_metadata_for_payment( + distribution_date: str, + program_id: int, + pool_address: str, + pool_asset_id: int, + pool_name: str, + first_cycle: str, + last_cycle: str, +): + data = { + "rewards": { + "distribution": f"{pool_asset_id}_{program_id}_{distribution_date}", + "pool_address": pool_address, + "distribution_date": distribution_date, + "pool_asset_id": int(pool_asset_id), + "program_id": int(program_id), + "pool_name": pool_name, + "first_cycle": first_cycle, + "last_cycle": last_cycle, + }, + } + return data + + +def generate_note_from_metadata(metadata): + note = b"tinymanStaking/v2:j" + json.dumps(metadata, sort_keys=True).encode() + return note + + +def get_note_prefix_for_distribution(distribution_date, pool_address): + metadata = prepare_reward_metadata_for_payment( + distribution_date, + program_id=None, + pool_address=pool_address, + pool_asset_id=None, + pool_name=None, + first_cycle=None, + last_cycle=None, + ) + note = generate_note_from_metadata(metadata) + prefix = note.split(b', "distribution_date"')[0] + return prefix + + +def get_note_version(note): + assert isinstance(note, (bytes, str)) + + if isinstance(note, bytes): + note = note.decode() + + m = re.match(r"^tinymanStaking/v(?P\w+):j.*", note) + if m is None: + raise ValueError("Invalid note.") + + version = m.group("version") + assert version in ["1", "2"] + return version + + +def get_reward_metadata_from_note(note: str): + assert isinstance(note, (bytes, str)) + + if isinstance(note, bytes): + note = note.decode() + + m = re.match(r"^tinymanStaking/v(?P\w+):j(?P.*)", note) + if m is None: + raise ValueError("Invalid note.") + + metadata = m.group("metadata") + metadata = json.loads(metadata) + return metadata + + +def parse_reward_payment_transaction(txn): + if "note" not in txn: + return + + if txn["tx-type"] == PAYMENT_TXN: + reward_asset_id = 0 + staker_address = txn["payment-transaction"]["receiver"] + transfer_amount = txn["payment-transaction"]["amount"] + elif txn["tx-type"] == ASSETTRANSFER_TXN: + reward_asset_id = txn["asset-transfer-transaction"]["asset-id"] + staker_address = txn["asset-transfer-transaction"]["receiver"] + transfer_amount = txn["asset-transfer-transaction"]["amount"] + else: + return + + note = b64decode(txn["note"]) + try: + note_version = get_note_version(note) + except ValueError: + return + note_prefix = f"tinymanStaking/v{note_version}:j" + + try: + note = note.decode() + except UnicodeDecodeError: + return + + if not note.startswith(note_prefix): + return + + data = json.loads(note.lstrip(note_prefix)) + if "rewards" not in data: + return + + payment_data = data["rewards"] + if not isinstance(payment_data, dict): + return + + if note_version == "1": + return _parse_reward_payment_transaction_v1( + payment_data=payment_data, + txn=txn, + reward_asset_id=reward_asset_id, + transfer_amount=transfer_amount, + staker_address=staker_address, + ) + + if note_version == "2": + return _parse_reward_payment_transaction_v2( + payment_data=payment_data, + txn=txn, + reward_asset_id=reward_asset_id, + transfer_amount=transfer_amount, + staker_address=staker_address, + ) + + +def _parse_reward_payment_transaction_v1( + *, payment_data, txn, reward_asset_id, transfer_amount, staker_address +): + if not { + "distribution", + "pool_address", + "pool_name", + "pool_asset_id", + "rewards", + } <= set(payment_data): + return + + if not isinstance(payment_data["rewards"], list): + return + + if not payment_data["rewards"]: + return + + try: + distribution_date, pool_address = payment_data["distribution"].split("_") + distribution_date = datetime.strptime(distribution_date, DATE_FORMAT).date() + except ValueError: + return + + if pool_address != payment_data["pool_address"]: + return + + if not is_valid_address(pool_address): + return + + try: + pool_asset_id = int(payment_data["pool_asset_id"]) + except ValueError: + return + + rewards = [] + try: + for cycle, reward_amount in payment_data["rewards"]: + rewards.append( + { + "cycle": datetime.strptime(cycle, DATE_FORMAT).date(), + "amount": int(reward_amount), + } + ) + except ValueError: + return + + total_reward = sum([reward["amount"] for reward in rewards]) + + if total_reward < transfer_amount: + return + + result = { + "version": "1", + "distribution": payment_data["distribution"], + "distribution_date": distribution_date, + "program_address": txn["sender"], + "staker_address": staker_address, + "pool_address": pool_address, + "pool_name": payment_data["pool_name"], + "pool_asset_id": pool_asset_id, + "reward_asset_id": reward_asset_id, + "total_amount": transfer_amount, + "rewards": rewards, + } + return result + + +def _parse_reward_payment_transaction_v2( + *, payment_data, txn, reward_asset_id, transfer_amount, staker_address +): + if not { + "distribution", + "pool_address", + "pool_name", + "pool_asset_id", + "program_id", + "distribution_date", + "first_cycle", + "last_cycle", + } <= set(payment_data): + return + + try: + pool_asset_id, program_id, distribution_date = payment_data[ + "distribution" + ].split("_") + pool_asset_id = int(pool_asset_id) + program_id = int(program_id) + except ValueError: + return + + if pool_asset_id != payment_data["pool_asset_id"]: + return + + if program_id != payment_data["program_id"]: + return + + if distribution_date != payment_data["distribution_date"]: + return + + try: + distribution_date = datetime.strptime(distribution_date, DATE_FORMAT).date() + first_cycle = datetime.strptime(payment_data["first_cycle"], DATE_FORMAT).date() + last_cycle = datetime.strptime(payment_data["last_cycle"], DATE_FORMAT).date() + except ValueError: + return + + if not is_valid_address(payment_data["pool_address"]): + return + + result = { + "version": "2", + "distribution": payment_data["distribution"], + "distribution_date": distribution_date, + "program_id": program_id, + "program_distribution_address": txn["sender"], + "staker_address": staker_address, + "pool_address": payment_data["pool_address"], + "pool_name": payment_data["pool_name"], + "pool_asset_id": pool_asset_id, + "reward_asset_id": reward_asset_id, + "total_amount": transfer_amount, + "first_cycle": first_cycle, + "last_cycle": last_cycle, + } + return result diff --git a/tinyman/staking/constants.py b/tinyman/staking/constants.py new file mode 100644 index 0000000..a82efa7 --- /dev/null +++ b/tinyman/staking/constants.py @@ -0,0 +1,4 @@ +DATE_FORMAT = "%Y%m%d" + +TESTNET_STAKING_APP_ID = 51948952 +MAINNET_STAKING_APP_ID = 649588853 diff --git a/tinyman/utils.py b/tinyman/utils.py index 5c05185..9c868fc 100644 --- a/tinyman/utils.py +++ b/tinyman/utils.py @@ -1,42 +1,26 @@ -from base64 import b64decode, b64encode import warnings -from algosdk.future.transaction import LogicSigTransaction, assign_group_id, wait_for_confirmation as wait_for_confirmation_algosdk +from base64 import b64decode, b64encode +from datetime import datetime +from algosdk.future.transaction import ( + LogicSigTransaction, + assign_group_id, + wait_for_confirmation, +) from algosdk.error import AlgodHTTPError -warnings.simplefilter('always', DeprecationWarning) - -def get_program(definition, variables=None): - """ - Return a byte array to be used in LogicSig. - """ - template = definition['bytecode'] - template_bytes = list(b64decode(template)) - - offset = 0 - for v in sorted(definition['variables'], key=lambda v: v['index']): - name = v['name'].split('TMPL_')[-1].lower() - value = variables[name] - start = v['index'] - offset - end = start + v['length'] - value_encoded = encode_value(value, v['type']) - value_encoded_len = len(value_encoded) - diff = v['length'] - value_encoded_len - offset += diff - template_bytes[start:end] = list(value_encoded) - - return bytes(template_bytes) +warnings.simplefilter("always", DeprecationWarning) def encode_value(value, type): - if type == 'int': + if type == "int": return encode_varint(value) - raise Exception('Unsupported value type %s!' % type) + raise Exception("Unsupported value type %s!" % type) def encode_varint(number): - buf = b'' + buf = b"" while True: - towrite = number & 0x7f + towrite = number & 0x7F number >>= 7 if number: buf += bytes([towrite | 0x80]) @@ -46,55 +30,111 @@ def encode_varint(number): return buf -def sign_and_submit_transactions(client, transactions, signed_transactions, sender, sender_sk): +def sign_and_submit_transactions( + client, transactions, signed_transactions, sender, sender_sk +): for i, txn in enumerate(transactions): if txn.sender == sender: signed_transactions[i] = txn.sign(sender_sk) - + txid = client.send_transactions(signed_transactions) - txinfo = wait_for_confirmation_algosdk(client, txid) - txinfo["txid"] = txid - return txinfo + txn_info = wait_for_confirmation(client, txid) + txn_info["txid"] = txid + return txn_info + +def int_to_bytes(num): + return num.to_bytes(8, "big") -def wait_for_confirmation(client, txid): - """ - Deprecated. - Use algosdk if you are importing wait_for_confirmation individually. - """ - warnings.warn('tinyman.utils.wait_for_confirmation is deprecated. Use algosdk.future.transaction.wait_for_confirmation instead if you are importing individually.', DeprecationWarning, stacklevel=2) - txinfo = wait_for_confirmation_algosdk(client, txid) - txinfo["txid"] = txid - return txinfo +def int_list_to_bytes(nums): + return b"".join([int_to_bytes(x) for x in nums]) + + +def bytes_to_int(b): + if type(b) == str: + b = b64decode(b) + return int.from_bytes(b, "big") -def int_to_bytes(num): - return num.to_bytes(8, 'big') + +def bytes_to_int_list(b): + n = len(b) // 8 + return [bytes_to_int(b[(i * 8) : ((i + 1) * 8)]) for i in range(n)] def get_state_int(state, key): if type(key) == str: key = b64encode(key.encode()) - return state.get(key.decode(), {'uint': 0})['uint'] + return state.get(key.decode(), {"uint": 0})["uint"] def get_state_bytes(state, key): if type(key) == str: key = b64encode(key.encode()) - return state.get(key.decode(), {'bytes': ''})['bytes'] + return state.get(key.decode(), {"bytes": ""})["bytes"] + + +def apply_delta(state, delta): + state = dict(state) + for d in delta: + key = b64decode(d["key"]) + if d["value"]["action"] == 1: + state[key] = b64decode(d["value"].get("bytes", "")) + elif d["value"]["action"] == 2: + state[key] = d["value"].get("uint", 0) + elif d["value"]["action"] == 3: + state.pop(key) + else: + raise Exception(d["value"]["action"]) + return state -class TransactionGroup: +def timestamp_to_date_str(t): + d = datetime.fromtimestamp(t).date() + return d.strftime("%Y-%m-%d") + +def calculate_price_impact( + input_supply, output_supply, swap_input_amount, swap_output_amount +): + swap_price = swap_output_amount / swap_input_amount + pool_price = output_supply / input_supply + price_impact = abs(round((swap_price / pool_price) - 1, 5)) + return price_impact + + +class TransactionGroup: def __init__(self, transactions): + # Clear previously assigned group ids + for txn in transactions: + txn.group = None + transactions = assign_group_id(transactions) self.transactions = transactions self.signed_transactions = [None for _ in self.transactions] - - def sign(self, user): - user.sign_transaction_group(self) - + + @property + def id(self): + try: + byte_group_id = self.transactions[0].group + except IndexError: + return + + group_id = b64encode(byte_group_id).decode("utf-8") + return group_id + def sign_with_logicisg(self, logicsig): + """ + Deprecated because of the typo. Use sign_with_logicsig instead. + """ + warnings.warn( + "tinyman.utils.TransactionGroup.sign_with_logicisg is deprecated. Use tinyman.utils.TransactionGroup.sign_with_logicsig instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.sign_with_logicsig(logicsig) + + def sign_with_logicsig(self, logicsig): address = logicsig.address() for i, txn in enumerate(self.transactions): if txn.sender == address: @@ -104,16 +144,19 @@ def sign_with_private_key(self, address, private_key): for i, txn in enumerate(self.transactions): if txn.sender == address: self.signed_transactions[i] = txn.sign(private_key) - + def submit(self, algod, wait=False): try: txid = algod.send_transactions(self.signed_transactions) except AlgodHTTPError as e: raise Exception(str(e)) - if wait: - txinfo = wait_for_confirmation_algosdk(algod, txid) - txinfo["txid"] = txid - return txinfo - return {'txid': txid} - + if wait: + txn_info = wait_for_confirmation(algod, txid) + txn_info["txid"] = txid + return txn_info + return {"txid": txid} + + def __add__(self, other): + transactions = self.transactions + other.transactions + return TransactionGroup(transactions) diff --git a/tinyman/v1/bootstrap.py b/tinyman/v1/bootstrap.py index 2109675..8de163f 100644 --- a/tinyman/v1/bootstrap.py +++ b/tinyman/v1/bootstrap.py @@ -1,21 +1,30 @@ -import base64 -from os import name -import algosdk -from algosdk.future.transaction import ApplicationOptInTxn, PaymentTxn, AssetCreateTxn, AssetOptInTxn -from algosdk.v2client.algod import AlgodClient +from algosdk.future.transaction import ( + ApplicationOptInTxn, + PaymentTxn, + AssetCreateTxn, + AssetOptInTxn, +) from tinyman.utils import int_to_bytes, TransactionGroup from .contracts import get_pool_logicsig -def prepare_bootstrap_transactions(validator_app_id, asset1_id, asset2_id, asset1_unit_name, asset2_unit_name, sender, suggested_params): +def prepare_bootstrap_transactions( + validator_app_id, + asset1_id, + asset2_id, + asset1_unit_name, + asset2_unit_name, + sender, + suggested_params, +): pool_logicsig = get_pool_logicsig(validator_app_id, asset1_id, asset2_id) pool_address = pool_logicsig.address() - assert(asset1_id > asset2_id) + assert asset1_id > asset2_id if asset2_id == 0: - asset2_unit_name = 'ALGO' + asset2_unit_name = "ALGO" txns = [ PaymentTxn( @@ -23,13 +32,13 @@ def prepare_bootstrap_transactions(validator_app_id, asset1_id, asset2_id, asset sp=suggested_params, receiver=pool_address, amt=961000 if asset2_id > 0 else 860000, - note='fee', + note="fee", ), ApplicationOptInTxn( sender=pool_address, sp=suggested_params, index=validator_app_id, - app_args=['bootstrap', int_to_bytes(asset1_id), int_to_bytes(asset2_id)], + app_args=["bootstrap", int_to_bytes(asset1_id), int_to_bytes(asset2_id)], foreign_assets=[asset1_id] if asset2_id == 0 else [asset1_id, asset2_id], ), AssetCreateTxn( @@ -37,9 +46,9 @@ def prepare_bootstrap_transactions(validator_app_id, asset1_id, asset2_id, asset sp=suggested_params, total=0xFFFFFFFFFFFFFFFF, decimals=6, - unit_name='TMPOOL11', - asset_name=f'TinymanPool1.1 {asset1_unit_name}-{asset2_unit_name}', - url='https://tinyman.org', + unit_name="TMPOOL11", + asset_name=f"TinymanPool1.1 {asset1_unit_name}-{asset2_unit_name}", + url="https://tinyman.org", default_frozen=False, ), AssetOptInTxn( @@ -57,5 +66,5 @@ def prepare_bootstrap_transactions(validator_app_id, asset1_id, asset2_id, asset ) ] txn_group = TransactionGroup(txns) - txn_group.sign_with_logicisg(pool_logicsig) - return txn_group \ No newline at end of file + txn_group.sign_with_logicsig(pool_logicsig) + return txn_group diff --git a/tinyman/v1/burn.py b/tinyman/v1/burn.py index d964ec1..4247abe 100644 --- a/tinyman/v1/burn.py +++ b/tinyman/v1/burn.py @@ -1,13 +1,20 @@ -import base64 -from os import name -import algosdk from algosdk.future.transaction import ApplicationNoOpTxn, PaymentTxn, AssetTransferTxn from tinyman.utils import TransactionGroup from .contracts import get_pool_logicsig -def prepare_burn_transactions(validator_app_id, asset1_id, asset2_id, liquidity_asset_id, asset1_amount, asset2_amount, liquidity_asset_amount, sender, suggested_params): +def prepare_burn_transactions( + validator_app_id, + asset1_id, + asset2_id, + liquidity_asset_id, + asset1_amount, + asset2_amount, + liquidity_asset_amount, + sender, + suggested_params, +): pool_logicsig = get_pool_logicsig(validator_app_id, asset1_id, asset2_id) pool_address = pool_logicsig.address() @@ -17,15 +24,17 @@ def prepare_burn_transactions(validator_app_id, asset1_id, asset2_id, liquidity_ sp=suggested_params, receiver=pool_address, amt=3000, - note='fee', + note="fee", ), ApplicationNoOpTxn( sender=pool_address, sp=suggested_params, index=validator_app_id, - app_args=['burn'], + app_args=["burn"], accounts=[sender], - foreign_assets=[asset1_id, liquidity_asset_id] if asset2_id == 0 else [asset1_id, asset2_id, liquidity_asset_id], + foreign_assets=[asset1_id, liquidity_asset_id] + if asset2_id == 0 + else [asset1_id, asset2_id, liquidity_asset_id], ), AssetTransferTxn( sender=pool_address, @@ -40,7 +49,9 @@ def prepare_burn_transactions(validator_app_id, asset1_id, asset2_id, liquidity_ receiver=sender, amt=int(asset2_amount), index=asset2_id, - ) if asset2_id != 0 else PaymentTxn( + ) + if asset2_id != 0 + else PaymentTxn( sender=pool_address, sp=suggested_params, receiver=sender, @@ -55,5 +66,5 @@ def prepare_burn_transactions(validator_app_id, asset1_id, asset2_id, liquidity_ ), ] txn_group = TransactionGroup(txns) - txn_group.sign_with_logicisg(pool_logicsig) + txn_group.sign_with_logicsig(pool_logicsig) return txn_group diff --git a/tinyman/v1/client.py b/tinyman/v1/client.py index 4bc3737..88a4908 100644 --- a/tinyman/v1/client.py +++ b/tinyman/v1/client.py @@ -1,39 +1,25 @@ -import json from base64 import b64decode from algosdk.v2client.algod import AlgodClient -from algosdk.error import AlgodHTTPError from algosdk.encoding import encode_address -from algosdk.future.transaction import wait_for_confirmation -from tinyman.assets import Asset, AssetAmount -from .optin import prepare_app_optin_transactions,prepare_asset_optin_transactions -from .constants import TESTNET_VALIDATOR_APP_ID, MAINNET_VALIDATOR_APP_ID +from tinyman.assets import AssetAmount +from tinyman.client import BaseTinymanClient +from tinyman.staking.constants import ( + TESTNET_STAKING_APP_ID, + MAINNET_STAKING_APP_ID, +) -class TinymanClient: - def __init__(self, algod_client: AlgodClient, validator_app_id: int, user_address=None): - self.algod = algod_client - self.validator_app_id = validator_app_id - self.assets_cache = {} - self.user_address = user_address - - def fetch_pool(self, asset1, asset2, fetch=True): - from .pools import Pool - return Pool(self, asset1, asset2, fetch=fetch) +from tinyman.optin import prepare_app_optin_transactions +from tinyman.v1.constants import ( + TESTNET_VALIDATOR_APP_ID, + MAINNET_VALIDATOR_APP_ID, +) - def fetch_asset(self, asset_id): - if asset_id not in self.assets_cache: - asset = Asset(asset_id) - asset.fetch(self.algod) - self.assets_cache[asset_id] = asset - return self.assets_cache[asset_id] +class TinymanClient(BaseTinymanClient): + def fetch_pool(self, asset1, asset2, fetch=True): + from .pools import Pool - def submit(self, transaction_group, wait=False): - txid = self.algod.send_transactions(transaction_group.signed_transactions) - if wait: - txinfo = wait_for_confirmation(self.algod, txid) - txinfo["txid"] = txid - return txinfo - return {'txid': txid} + return Pool(self, asset1, asset2, fetch=fetch) def prepare_app_optin_transactions(self, user_address=None): user_address = user_address or self.user_address @@ -45,64 +31,53 @@ def prepare_app_optin_transactions(self, user_address=None): ) return txn_group - def prepare_asset_optin_transactions(self, asset_id, user_address=None): - assert asset_id != 0, "Cannot opt into ALGO" - user_address = user_address or self.user_address - 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 fetch_excess_amounts(self, user_address=None): user_address = user_address or self.user_address account_info = self.algod.account_info(user_address) try: - validator_app = [a for a in account_info['apps-local-state'] if a['id'] == self.validator_app_id][0] + validator_app = [ + a + for a in account_info["apps-local-state"] + if a["id"] == self.validator_app_id + ][0] except IndexError: return {} try: - validator_app_state = {x['key']: x['value'] for x in validator_app['key-value']} + validator_app_state = { + x["key"]: x["value"] for x in validator_app["key-value"] + } except KeyError: return {} pools = {} for key in validator_app_state: b = b64decode(key.encode()) - if b[-9:-8] == b'e': - value = validator_app_state[key]['uint'] + if b[-9:-8] == b"e": + value = validator_app_state[key]["uint"] pool_address = encode_address(b[:-9]) pools[pool_address] = pools.get(pool_address, {}) - asset_id = int.from_bytes(b[-8:], 'big') + asset_id = int.from_bytes(b[-8:], "big") asset = self.fetch_asset(asset_id) pools[pool_address][asset] = AssetAmount(asset, value) return pools - - def is_opted_in(self, user_address=None): - user_address = user_address or self.user_address - account_info = self.algod.account_info(user_address) - for a in account_info.get('apps-local-state', []): - if a['id'] == self.validator_app_id: - return True - return False - - def asset_is_opted_in(self, asset_id, user_address=None): - user_address = user_address or self.user_address - 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 class TinymanTestnetClient(TinymanClient): def __init__(self, algod_client: AlgodClient, user_address=None): - super().__init__(algod_client, validator_app_id=TESTNET_VALIDATOR_APP_ID, user_address=user_address) + super().__init__( + algod_client, + validator_app_id=TESTNET_VALIDATOR_APP_ID, + user_address=user_address, + staking_app_id=TESTNET_STAKING_APP_ID, + ) class TinymanMainnetClient(TinymanClient): def __init__(self, algod_client: AlgodClient, user_address=None): - super().__init__(algod_client, validator_app_id=MAINNET_VALIDATOR_APP_ID, user_address=user_address) + super().__init__( + algod_client, + validator_app_id=MAINNET_VALIDATOR_APP_ID, + user_address=user_address, + staking_app_id=MAINNET_STAKING_APP_ID, + ) diff --git a/tinyman/v1/contracts.py b/tinyman/v1/contracts.py index 7e3413d..53e8a26 100644 --- a/tinyman/v1/contracts.py +++ b/tinyman/v1/contracts.py @@ -1,23 +1,49 @@ import json import importlib.resources -from algosdk.future.transaction import LogicSig +from algosdk.future.transaction import LogicSigAccount import tinyman.v1 -from tinyman.utils import get_program +from base64 import b64decode +from tinyman.utils import encode_value -_contracts = json.loads(importlib.resources.read_text(tinyman.v1, 'asc.json')) +_contracts = json.loads(importlib.resources.read_text(tinyman.v1, "asc.json")) -pool_logicsig_def = _contracts['contracts']['pool_logicsig']['logic'] +pool_logicsig_def = _contracts["contracts"]["pool_logicsig"]["logic"] -validator_app_def = _contracts['contracts']['validator_app'] +validator_app_def = _contracts["contracts"]["validator_app"] + + +def get_program(definition, variables=None): + """ + Return a byte array to be used in LogicSig. + """ + template = definition["bytecode"] + template_bytes = list(b64decode(template)) + + offset = 0 + for v in sorted(definition["variables"], key=lambda v: v["index"]): + name = v["name"].split("TMPL_")[-1].lower() + value = variables[name] + start = v["index"] - offset + end = start + v["length"] + value_encoded = encode_value(value, v["type"]) + value_encoded_len = len(value_encoded) + diff = v["length"] - value_encoded_len + offset += diff + template_bytes[start:end] = list(value_encoded) + + return bytes(template_bytes) def get_pool_logicsig(validator_app_id, asset1_id, asset2_id): assets = [asset1_id, asset2_id] asset_id_1 = max(assets) asset_id_2 = min(assets) - program_bytes = get_program(pool_logicsig_def, variables=dict( - validator_app_id=validator_app_id, - asset_id_1=asset_id_1, - asset_id_2=asset_id_2, - )) - return LogicSig(program=program_bytes) + program_bytes = get_program( + pool_logicsig_def, + variables=dict( + validator_app_id=validator_app_id, + asset_id_1=asset_id_1, + asset_id_2=asset_id_2, + ), + ) + return LogicSigAccount(program=program_bytes) diff --git a/tinyman/v1/fees.py b/tinyman/v1/fees.py index 69cf57e..334f967 100644 --- a/tinyman/v1/fees.py +++ b/tinyman/v1/fees.py @@ -1,13 +1,19 @@ -import base64 -from os import name -import algosdk from algosdk.future.transaction import ApplicationNoOpTxn, PaymentTxn, AssetTransferTxn from tinyman.utils import TransactionGroup from .contracts import get_pool_logicsig -def prepare_redeem_fees_transactions(validator_app_id, asset1_id, asset2_id, liquidity_asset_id, amount, creator, sender, suggested_params): +def prepare_redeem_fees_transactions( + validator_app_id, + asset1_id, + asset2_id, + liquidity_asset_id, + amount, + creator, + sender, + suggested_params, +): pool_logicsig = get_pool_logicsig(validator_app_id, asset1_id, asset2_id) pool_address = pool_logicsig.address() @@ -17,14 +23,16 @@ def prepare_redeem_fees_transactions(validator_app_id, asset1_id, asset2_id, liq sp=suggested_params, receiver=pool_address, amt=2000, - note='fee', + note="fee", ), ApplicationNoOpTxn( sender=pool_address, sp=suggested_params, index=validator_app_id, - app_args=['fees'], - foreign_assets=[asset1_id, liquidity_asset_id] if asset2_id == 0 else [asset1_id, asset2_id, liquidity_asset_id], + app_args=["fees"], + foreign_assets=[asset1_id, liquidity_asset_id] + if asset2_id == 0 + else [asset1_id, asset2_id, liquidity_asset_id], ), AssetTransferTxn( sender=pool_address, @@ -32,8 +40,8 @@ def prepare_redeem_fees_transactions(validator_app_id, asset1_id, asset2_id, liq receiver=creator, amt=int(amount), index=liquidity_asset_id, - ) + ), ] txn_group = TransactionGroup(txns) - txn_group.sign_with_logicisg(pool_logicsig) + txn_group.sign_with_logicsig(pool_logicsig) return txn_group diff --git a/tinyman/v1/mint.py b/tinyman/v1/mint.py index 42add5a..550efe0 100644 --- a/tinyman/v1/mint.py +++ b/tinyman/v1/mint.py @@ -1,13 +1,20 @@ -import base64 -from os import name -import algosdk from algosdk.future.transaction import ApplicationNoOpTxn, PaymentTxn, AssetTransferTxn from tinyman.utils import TransactionGroup from .contracts import get_pool_logicsig -def prepare_mint_transactions(validator_app_id, asset1_id, asset2_id, liquidity_asset_id, asset1_amount, asset2_amount, liquidity_asset_amount, sender, suggested_params): +def prepare_mint_transactions( + validator_app_id, + asset1_id, + asset2_id, + liquidity_asset_id, + asset1_amount, + asset2_amount, + liquidity_asset_amount, + sender, + suggested_params, +): pool_logicsig = get_pool_logicsig(validator_app_id, asset1_id, asset2_id) pool_address = pool_logicsig.address() @@ -17,15 +24,17 @@ def prepare_mint_transactions(validator_app_id, asset1_id, asset2_id, liquidity_ sp=suggested_params, receiver=pool_address, amt=2000, - note='fee', + note="fee", ), ApplicationNoOpTxn( sender=pool_address, sp=suggested_params, index=validator_app_id, - app_args=['mint'], + app_args=["mint"], accounts=[sender], - foreign_assets=[asset1_id, liquidity_asset_id] if asset2_id == 0 else [asset1_id, asset2_id, liquidity_asset_id], + foreign_assets=[asset1_id, liquidity_asset_id] + if asset2_id == 0 + else [asset1_id, asset2_id, liquidity_asset_id], ), AssetTransferTxn( sender=sender, @@ -40,7 +49,9 @@ def prepare_mint_transactions(validator_app_id, asset1_id, asset2_id, liquidity_ receiver=pool_address, amt=int(asset2_amount), index=asset2_id, - ) if asset2_id != 0 else PaymentTxn( + ) + if asset2_id != 0 + else PaymentTxn( sender=sender, sp=suggested_params, receiver=pool_address, @@ -55,5 +66,5 @@ def prepare_mint_transactions(validator_app_id, asset1_id, asset2_id, liquidity_ ), ] txn_group = TransactionGroup(txns) - txn_group.sign_with_logicisg(pool_logicsig) + txn_group.sign_with_logicsig(pool_logicsig) return txn_group diff --git a/tinyman/v1/optout.py b/tinyman/v1/optout.py index 6e20e40..ceef150 100644 --- a/tinyman/v1/optout.py +++ b/tinyman/v1/optout.py @@ -1,10 +1,6 @@ -import base64 -import algosdk from algosdk.future.transaction import ApplicationClearStateTxn from algosdk.v2client.algod import AlgodClient -from .contracts import validator_app_def - def get_optout_transactions(client: AlgodClient, sender, validator_app_id): suggested_params = client.suggested_params() diff --git a/tinyman/v1/pools.py b/tinyman/v1/pools.py index 1839e96..ec99375 100644 --- a/tinyman/v1/pools.py +++ b/tinyman/v1/pools.py @@ -1,20 +1,19 @@ import math from dataclasses import dataclass -from base64 import b64encode, b64decode +from base64 import b64encode from algosdk.v2client.algod import AlgodClient from algosdk.encoding import decode_address from .contracts import get_pool_logicsig -from tinyman.utils import get_state_int, get_state_bytes +from tinyman.utils import get_state_int, calculate_price_impact 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 .optin import prepare_asset_optin_transactions +from tinyman.optin import prepare_asset_optin_transactions from .fees import prepare_redeem_fees_transactions from .client import TinymanClient -from tinyman.v1 import swap def get_pool_info(client: AlgodClient, validator_app_id, asset1_id, asset2_id): @@ -26,54 +25,62 @@ def get_pool_info(client: AlgodClient, validator_app_id, asset1_id, asset2_id): def get_pool_info_from_account_info(account_info): try: - validator_app_id = account_info['apps-local-state'][0]['id'] + validator_app_id = account_info["apps-local-state"][0]["id"] except IndexError: return {} - validator_app_state = {x['key']: x['value'] for x in account_info['apps-local-state'][0]['key-value']} + validator_app_state = { + x["key"]: x["value"] for x in account_info["apps-local-state"][0]["key-value"] + } - asset1_id = get_state_int(validator_app_state, 'a1') - asset2_id = get_state_int(validator_app_state, 'a2') + asset1_id = get_state_int(validator_app_state, "a1") + asset2_id = get_state_int(validator_app_state, "a2") pool_logicsig = get_pool_logicsig(validator_app_id, asset1_id, asset2_id) pool_address = pool_logicsig.address() - assert(account_info['address'] == pool_address) + assert account_info["address"] == pool_address - asset1_reserves = get_state_int(validator_app_state, 's1') - asset2_reserves = get_state_int(validator_app_state, 's2') - issued_liquidity = get_state_int(validator_app_state, 'ilt') - unclaimed_protocol_fees = get_state_int(validator_app_state, 'p') + asset1_reserves = get_state_int(validator_app_state, "s1") + asset2_reserves = get_state_int(validator_app_state, "s2") + issued_liquidity = get_state_int(validator_app_state, "ilt") + unclaimed_protocol_fees = get_state_int(validator_app_state, "p") - liquidity_asset = account_info['created-assets'][0] - liquidity_asset_id = liquidity_asset['index'] + liquidity_asset = account_info["created-assets"][0] + liquidity_asset_id = liquidity_asset["index"] - outstanding_asset1_amount = get_state_int(validator_app_state, b64encode(b'o' + (asset1_id).to_bytes(8, 'big'))) - outstanding_asset2_amount = get_state_int(validator_app_state, b64encode(b'o' + (asset2_id).to_bytes(8, 'big'))) - outstanding_liquidity_asset_amount = get_state_int(validator_app_state, b64encode(b'o' + (liquidity_asset_id).to_bytes(8, 'big'))) + outstanding_asset1_amount = get_state_int( + validator_app_state, b64encode(b"o" + (asset1_id).to_bytes(8, "big")) + ) + outstanding_asset2_amount = get_state_int( + validator_app_state, b64encode(b"o" + (asset2_id).to_bytes(8, "big")) + ) + outstanding_liquidity_asset_amount = get_state_int( + validator_app_state, b64encode(b"o" + (liquidity_asset_id).to_bytes(8, "big")) + ) pool = { - 'address': pool_address, - 'asset1_id': asset1_id, - 'asset2_id': asset2_id, - 'liquidity_asset_id': liquidity_asset['index'], - 'liquidity_asset_name': liquidity_asset['params']['name'], - 'asset1_reserves': asset1_reserves, - 'asset2_reserves': asset2_reserves, - 'issued_liquidity': issued_liquidity, - 'unclaimed_protocol_fees': unclaimed_protocol_fees, - 'outstanding_asset1_amount': outstanding_asset1_amount, - 'outstanding_asset2_amount': outstanding_asset2_amount, - 'outstanding_liquidity_asset_amount': outstanding_liquidity_asset_amount, - 'validator_app_id': validator_app_id, - 'algo_balance': account_info['amount'], - 'round': account_info['round'], + "address": pool_address, + "asset1_id": asset1_id, + "asset2_id": asset2_id, + "liquidity_asset_id": liquidity_asset["index"], + "liquidity_asset_name": liquidity_asset["params"]["name"], + "asset1_reserves": asset1_reserves, + "asset2_reserves": asset2_reserves, + "issued_liquidity": issued_liquidity, + "unclaimed_protocol_fees": unclaimed_protocol_fees, + "outstanding_asset1_amount": outstanding_asset1_amount, + "outstanding_asset2_amount": outstanding_asset2_amount, + "outstanding_liquidity_asset_amount": outstanding_liquidity_asset_amount, + "validator_app_id": validator_app_id, + "algo_balance": account_info["amount"], + "round": account_info["round"], } return pool def get_excess_asset_key(pool_address, asset_id): a = decode_address(pool_address) - key = b64encode(a + b'e' + (asset_id).to_bytes(8, 'big')) + key = b64encode(a + b"e" + (asset_id).to_bytes(8, "big")) return key @@ -88,14 +95,14 @@ class SwapQuote: @property def amount_out_with_slippage(self) -> AssetAmount: - if self.swap_type == 'fixed-output': + if self.swap_type == "fixed-output": return self.amount_out else: return self.amount_out - (self.amount_out * self.slippage) @property def amount_in_with_slippage(self) -> AssetAmount: - if self.swap_type == 'fixed-input': + if self.swap_type == "fixed-input": return self.amount_in else: return self.amount_in + (self.amount_in * self.slippage) @@ -106,23 +113,27 @@ def price(self) -> float: @property def price_with_slippage(self) -> float: - return self.amount_out_with_slippage.amount / self.amount_in_with_slippage.amount + return ( + self.amount_out_with_slippage.amount / self.amount_in_with_slippage.amount + ) @dataclass class MintQuote: - amounts_in: 'dict[AssetAmount]' + amounts_in: "dict[AssetAmount]" liquidity_asset_amount: AssetAmount slippage: float @property def liquidity_asset_amount_with_slippage(self) -> int: - return self.liquidity_asset_amount - (self.liquidity_asset_amount * self.slippage) + return self.liquidity_asset_amount - ( + self.liquidity_asset_amount * self.slippage + ) @dataclass class BurnQuote: - amounts_out: 'dict[AssetAmount]' + amounts_out: "dict[AssetAmount]" liquidity_asset_amount: AssetAmount slippage: float @@ -135,9 +146,21 @@ def amounts_out_with_slippage(self) -> dict: class Pool: - def __init__(self, client: TinymanClient, asset_a: Asset, asset_b: Asset, info=None, fetch=True, validator_app_id=None) -> None: + def __init__( + self, + client: TinymanClient, + asset_a: Asset, + asset_b: Asset, + info=None, + fetch=True, + validator_app_id=None, + ) -> None: self.client = client - self.validator_app_id = validator_app_id if validator_app_id is not None else client.validator_app_id + self.validator_app_id = ( + validator_app_id + if validator_app_id is not None + else client.validator_app_id + ) if isinstance(asset_a, int): asset_a = client.fetch_asset(asset_a) @@ -165,42 +188,61 @@ def __init__(self, client: TinymanClient, asset_a: Asset, asset_b: Asset, info=N self.refresh() elif info is not None: self.update_from_info(info) - + @classmethod def from_account_info(cls, account_info, client=None): info = get_pool_info_from_account_info(account_info) - pool = Pool(client, info['asset1_id'], info['asset2_id'], info, validator_app_id=info['validator_app_id']) + pool = Pool( + client, + info["asset1_id"], + info["asset2_id"], + info, + validator_app_id=info["validator_app_id"], + ) return pool def refresh(self, info=None): if info is None: - info = get_pool_info(self.client.algod, self.validator_app_id, self.asset1.id, self.asset2.id) + info = get_pool_info( + self.client.algod, self.validator_app_id, self.asset1.id, self.asset2.id + ) if not info: return self.update_from_info(info) - + def update_from_info(self, info): - if info['liquidity_asset_id'] is not None: + if info["liquidity_asset_id"] is not None: self.exists = True - self.liquidity_asset = Asset(info['liquidity_asset_id'], name=info['liquidity_asset_name'], unit_name='TMPOOL11', decimals=6) - self.asset1_reserves = info['asset1_reserves'] - self.asset2_reserves = info['asset2_reserves'] - self.issued_liquidity = info['issued_liquidity'] - self.unclaimed_protocol_fees = info['unclaimed_protocol_fees'] - self.outstanding_asset1_amount = info['outstanding_asset1_amount'] - self.outstanding_asset2_amount = info['outstanding_asset2_amount'] - self.outstanding_liquidity_asset_amount = info['outstanding_liquidity_asset_amount'] - self.last_refreshed_round = info['round'] - - self.algo_balance = info['algo_balance'] + self.liquidity_asset = Asset( + info["liquidity_asset_id"], + name=info["liquidity_asset_name"], + unit_name="TMPOOL11", + decimals=6, + ) + self.asset1_reserves = info["asset1_reserves"] + self.asset2_reserves = info["asset2_reserves"] + self.issued_liquidity = info["issued_liquidity"] + self.unclaimed_protocol_fees = info["unclaimed_protocol_fees"] + self.outstanding_asset1_amount = info["outstanding_asset1_amount"] + self.outstanding_asset2_amount = info["outstanding_asset2_amount"] + self.outstanding_liquidity_asset_amount = info[ + "outstanding_liquidity_asset_amount" + ] + self.last_refreshed_round = info["round"] + + self.algo_balance = info["algo_balance"] self.min_balance = self.get_minimum_balance() if self.asset2.id == 0: - self.asset2_reserves = (self.algo_balance - self.min_balance) - self.outstanding_asset2_amount - + self.asset2_reserves = ( + self.algo_balance - self.min_balance + ) - self.outstanding_asset2_amount + def get_logicsig(self): - pool_logicsig = get_pool_logicsig(self.validator_app_id, self.asset1.id, self.asset2.id) + pool_logicsig = get_pool_logicsig( + self.validator_app_id, self.asset1.id, self.asset2.id + ) return pool_logicsig - + @property def address(self): logicsig = self.get_logicsig() @@ -217,21 +259,21 @@ def asset2_price(self): def info(self): pool = { - 'address': self.address, - 'asset1_id': self.asset1.id, - 'asset2_id': self.asset2.id, - 'asset1_unit_name': self.asset1.unit_name, - 'asset2_unit_name': self.asset2.unit_name, - 'liquidity_asset_id': self.liquidity_asset.id, - 'liquidity_asset_name': self.liquidity_asset.name, - 'asset1_reserves': self.asset1_reserves, - 'asset2_reserves': self.asset2_reserves, - 'issued_liquidity': self.issued_liquidity, - 'unclaimed_protocol_fees': self.unclaimed_protocol_fees, - 'outstanding_asset1_amount': self.outstanding_asset1_amount, - 'outstanding_asset2_amount': self.outstanding_asset2_amount, - 'outstanding_liquidity_asset_amount': self.outstanding_liquidity_asset_amount, - 'last_refreshed_round': self.last_refreshed_round, + "address": self.address, + "asset1_id": self.asset1.id, + "asset2_id": self.asset2.id, + "asset1_unit_name": self.asset1.unit_name, + "asset2_unit_name": self.asset2.unit_name, + "liquidity_asset_id": self.liquidity_asset.id, + "liquidity_asset_name": self.liquidity_asset.name, + "asset1_reserves": self.asset1_reserves, + "asset2_reserves": self.asset2_reserves, + "issued_liquidity": self.issued_liquidity, + "unclaimed_protocol_fees": self.unclaimed_protocol_fees, + "outstanding_asset1_amount": self.outstanding_asset1_amount, + "outstanding_asset2_amount": self.outstanding_asset2_amount, + "outstanding_liquidity_asset_amount": self.outstanding_liquidity_asset_amount, + "last_refreshed_round": self.last_refreshed_round, } return pool @@ -240,13 +282,15 @@ def convert(self, amount: AssetAmount): return AssetAmount(self.asset2, int(amount.amount * self.asset1_price)) elif amount.asset == self.asset2: return AssetAmount(self.asset1, int(amount.amount * self.asset2_price)) - - def fetch_mint_quote(self, amount_a: AssetAmount, amount_b: AssetAmount=None, slippage=0.05): + + def fetch_mint_quote( + self, amount_a: AssetAmount, amount_b: AssetAmount = None, slippage=0.05 + ): amount1 = amount_a if amount_a.asset == self.asset1 else amount_b amount2 = amount_a if amount_a.asset == self.asset2 else amount_b self.refresh() if not self.exists: - raise Exception('Pool has not been bootstrapped yet!') + raise Exception("Pool has not been bootstrapped yet!") if self.issued_liquidity: if amount1 is None: amount1 = self.convert(amount2) @@ -258,9 +302,10 @@ def fetch_mint_quote(self, amount_a: AssetAmount, amount_b: AssetAmount=None, sl amount1.amount * self.issued_liquidity / self.asset1_reserves, amount2.amount * self.issued_liquidity / self.asset2_reserves, ) - else: # first mint + else: + # first mint if not amount1 or not amount2: - raise Exception('Amounts required for both assets for first mint!') + raise Exception("Amounts required for both assets for first mint!") liquidity_asset_amount = math.sqrt(amount1.amount * amount2.amount) - 1000 # don't apply slippage tolerance to first mint slippage = 0 @@ -270,7 +315,9 @@ def fetch_mint_quote(self, amount_a: AssetAmount, amount_b: AssetAmount=None, sl self.asset1: amount1, self.asset2: amount2, }, - liquidity_asset_amount=AssetAmount(self.liquidity_asset, liquidity_asset_amount), + liquidity_asset_amount=AssetAmount( + self.liquidity_asset, liquidity_asset_amount + ), slippage=slippage, ) return quote @@ -279,8 +326,12 @@ def fetch_burn_quote(self, liquidity_asset_in, slippage=0.05): if isinstance(liquidity_asset_in, int): liquidity_asset_in = AssetAmount(self.liquidity_asset, liquidity_asset_in) self.refresh() - asset1_amount = (liquidity_asset_in.amount * self.asset1_reserves) / self.issued_liquidity - asset2_amount = (liquidity_asset_in.amount * self.asset2_reserves) / self.issued_liquidity + asset1_amount = ( + liquidity_asset_in.amount * self.asset1_reserves + ) / self.issued_liquidity + asset2_amount = ( + liquidity_asset_in.amount * self.asset2_reserves + ) / self.issued_liquidity quote = BurnQuote( amounts_out={ @@ -292,7 +343,9 @@ def fetch_burn_quote(self, liquidity_asset_in, slippage=0.05): ) return quote - def fetch_fixed_input_swap_quote(self, amount_in: AssetAmount, slippage=0.05) -> SwapQuote: + def fetch_fixed_input_swap_quote( + self, amount_in: AssetAmount, slippage=0.05 + ) -> SwapQuote: asset_in, asset_in_amount = amount_in.asset, amount_in.amount self.refresh() if asset_in == self.asset1: @@ -305,33 +358,40 @@ def fetch_fixed_input_swap_quote(self, amount_in: AssetAmount, slippage=0.05) -> output_supply = self.asset1_reserves if not input_supply or not output_supply: - raise Exception('Pool has no liquidity!') - + raise Exception("Pool has no liquidity!") + # k = input_supply * output_supply - # ignoring fees, k must remain constant + # ignoring fees, k must remain constant # (input_supply + asset_in) * (output_supply - amount_out) = k k = input_supply * output_supply asset_in_amount_minus_fee = (asset_in_amount * 997) / 1000 swap_fees = asset_in_amount - asset_in_amount_minus_fee - asset_out_amount = output_supply - (k / (input_supply + asset_in_amount_minus_fee)) + asset_out_amount = output_supply - ( + k / (input_supply + asset_in_amount_minus_fee) + ) amount_out = AssetAmount(asset_out, int(asset_out_amount)) - swap_price = amount_out.amount / amount_in.amount - pool_price = output_supply / input_supply - price_impact = abs(round((swap_price / pool_price) - 1, 5)) + price_impact = calculate_price_impact( + input_supply=input_supply, + output_supply=output_supply, + swap_input_amount=amount_in.amount, + swap_output_amount=amount_out.amount, + ) quote = SwapQuote( - swap_type='fixed-input', + swap_type="fixed-input", amount_in=amount_in, amount_out=amount_out, swap_fees=AssetAmount(amount_in.asset, int(swap_fees)), slippage=slippage, - price_impact=price_impact + price_impact=price_impact, ) return quote - def fetch_fixed_output_swap_quote(self, amount_out: AssetAmount, slippage=0.05) -> SwapQuote: + def fetch_fixed_output_swap_quote( + self, amount_out: AssetAmount, slippage=0.05 + ) -> SwapQuote: asset_out, asset_out_amount = amount_out.asset, amount_out.amount self.refresh() if asset_out == self.asset1: @@ -342,34 +402,45 @@ def fetch_fixed_output_swap_quote(self, amount_out: AssetAmount, slippage=0.05) asset_in = self.asset1 input_supply = self.asset1_reserves output_supply = self.asset2_reserves - + # k = input_supply * output_supply - # ignoring fees, k must remain constant + # ignoring fees, k must remain constant # (input_supply + asset_in) * (output_supply - amount_out) = k k = input_supply * output_supply - calculated_amount_in_without_fee = (k / (output_supply - asset_out_amount)) - input_supply - asset_in_amount = calculated_amount_in_without_fee * 1000/997 + calculated_amount_in_without_fee = ( + k / (output_supply - asset_out_amount) + ) - input_supply + asset_in_amount = calculated_amount_in_without_fee * 1000 / 997 swap_fees = asset_in_amount - calculated_amount_in_without_fee amount_in = AssetAmount(asset_in, int(asset_in_amount)) - swap_price = amount_out.amount / amount_in.amount - pool_price = output_supply / input_supply - price_impact = abs(round((swap_price / pool_price) - 1, 5)) + price_impact = calculate_price_impact( + input_supply=input_supply, + output_supply=output_supply, + swap_input_amount=amount_in.amount, + swap_output_amount=amount_out.amount, + ) quote = SwapQuote( - swap_type='fixed-output', + swap_type="fixed-output", amount_out=amount_out, amount_in=amount_in, swap_fees=AssetAmount(amount_in.asset, int(swap_fees)), slippage=slippage, - price_impact=price_impact + price_impact=price_impact, ) return quote - def prepare_swap_transactions(self, amount_in: AssetAmount, amount_out: AssetAmount, swap_type, swapper_address=None): + def prepare_swap_transactions( + self, + amount_in: AssetAmount, + amount_out: AssetAmount, + swap_type, + swapper_address=None, + ): swapper_address = swapper_address or self.client.user_address suggested_params = self.client.algod.suggested_params() txn_group = prepare_swap_transactions( @@ -379,14 +450,16 @@ def prepare_swap_transactions(self, amount_in: AssetAmount, amount_out: AssetAmo liquidity_asset_id=self.liquidity_asset.id, asset_in_id=amount_in.asset.id, asset_in_amount=amount_in.amount, - asset_out_amount=amount_out.amount, - swap_type=swap_type, + asset_out_amount=amount_out.amount, + swap_type=swap_type, sender=swapper_address, suggested_params=suggested_params, ) return txn_group - - def prepare_swap_transactions_from_quote(self, quote: SwapQuote, swapper_address=None): + + def prepare_swap_transactions_from_quote( + self, quote: SwapQuote, swapper_address=None + ): return self.prepare_swap_transactions( amount_in=quote.amount_in_with_slippage, amount_out=quote.amount_out_with_slippage, @@ -408,7 +481,12 @@ def prepare_bootstrap_transactions(self, pooler_address=None): ) return txn_group - def prepare_mint_transactions(self, amounts_in: "dict[Asset, AssetAmount]", liquidity_asset_amount: AssetAmount, pooler_address=None): + def prepare_mint_transactions( + self, + amounts_in: "dict[Asset, AssetAmount]", + liquidity_asset_amount: AssetAmount, + pooler_address=None, + ): pooler_address = pooler_address or self.client.user_address asset1_amount = amounts_in[self.asset1] asset2_amount = amounts_in[self.asset2] @@ -426,16 +504,22 @@ def prepare_mint_transactions(self, amounts_in: "dict[Asset, AssetAmount]", liqu ) return txn_group - def prepare_mint_transactions_from_quote(self, quote: MintQuote, pooler_address=None): + def prepare_mint_transactions_from_quote( + self, quote: MintQuote, pooler_address=None + ): return self.prepare_mint_transactions( amounts_in=quote.amounts_in, liquidity_asset_amount=quote.liquidity_asset_amount_with_slippage, pooler_address=pooler_address, ) - def prepare_burn_transactions(self, liquidity_asset_amount: AssetAmount, amounts_out, pooler_address=None): + def prepare_burn_transactions( + self, liquidity_asset_amount: AssetAmount, amounts_out, pooler_address=None + ): if isinstance(liquidity_asset_amount, int): - liquidity_asset_amount = AssetAmount(self.liquidity_asset, liquidity_asset_amount) + liquidity_asset_amount = AssetAmount( + self.liquidity_asset, liquidity_asset_amount + ) pooler_address = pooler_address or self.client.user_address asset1_amount = amounts_out[self.asset1] asset2_amount = amounts_out[self.asset2] @@ -453,7 +537,9 @@ def prepare_burn_transactions(self, liquidity_asset_amount: AssetAmount, amounts ) return txn_group - def prepare_burn_transactions_from_quote(self, quote: BurnQuote, pooler_address=None): + def prepare_burn_transactions_from_quote( + self, quote: BurnQuote, pooler_address=None + ): return self.prepare_burn_transactions( liquidity_asset_amount=quote.liquidity_asset_amount, amounts_out=quote.amounts_out_with_slippage, @@ -484,7 +570,7 @@ def prepare_liquidity_asset_optin_transactions(self, user_address=None): suggested_params=suggested_params, ) return txn_group - + def prepare_redeem_fees_transactions(self, amount, creator, user_address=None): user_address = user_address or self.client.user_address suggested_params = self.client.algod.suggested_params() @@ -513,41 +599,49 @@ def get_minimum_balance(self): total_uints = 16 total_byteslices = 0 - total = MIN_BALANCE_PER_ACCOUNT + \ - (MIN_BALANCE_PER_ASSET * num_assets) + \ - (MIN_BALANCE_PER_APP * (num_created_apps + num_local_apps)) + \ - (MIN_BALANCE_PER_APP_UINT * total_uints) + \ - (MIN_BALANCE_PER_APP_BYTESLICE * total_byteslices) + total = ( + MIN_BALANCE_PER_ACCOUNT + + (MIN_BALANCE_PER_ASSET * num_assets) + + (MIN_BALANCE_PER_APP * (num_created_apps + num_local_apps)) + + (MIN_BALANCE_PER_APP_UINT * total_uints) + + (MIN_BALANCE_PER_APP_BYTESLICE * total_byteslices) + ) return total def fetch_excess_amounts(self, user_address=None): user_address = user_address or self.client.user_address - pool_excess = self.client.fetch_excess_amounts(user_address).get(self.address, {}) + pool_excess = self.client.fetch_excess_amounts(user_address).get( + self.address, {} + ) return pool_excess - + def fetch_pool_position(self, pooler_address=None): pooler_address = pooler_address or self.client.user_address account_info = self.client.algod.account_info(pooler_address) - assets = {a['asset-id']: a for a in account_info['assets']} - liquidity_asset_amount = assets.get(self.liquidity_asset.id, {}).get('amount', 0) + assets = {a["asset-id"]: a for a in account_info["assets"]} + liquidity_asset_amount = assets.get(self.liquidity_asset.id, {}).get( + "amount", 0 + ) quote = self.fetch_burn_quote(liquidity_asset_amount) return { self.asset1: quote.amounts_out[self.asset1], self.asset2: quote.amounts_out[self.asset2], self.liquidity_asset: quote.liquidity_asset_amount, - 'share': (liquidity_asset_amount / self.issued_liquidity), + "share": (liquidity_asset_amount / self.issued_liquidity), } def fetch_state(self, key=None): account_info = self.client.algod.account_info(self.address) try: - validator_app_id = account_info['apps-local-state'][0]['id'] + _ = account_info["apps-local-state"][0]["id"] except IndexError: return {} - validator_app_state = {x['key']: x['value'] for x in account_info['apps-local-state'][0]['key-value']} + validator_app_state = { + x["key"]: x["value"] + for x in account_info["apps-local-state"][0]["key-value"] + } if key: return get_state_int(validator_app_state, key) else: return validator_app_state - diff --git a/tinyman/v1/redeem.py b/tinyman/v1/redeem.py index 9a2cf2b..af19c38 100644 --- a/tinyman/v1/redeem.py +++ b/tinyman/v1/redeem.py @@ -1,13 +1,19 @@ -import base64 -from os import name -import algosdk from algosdk.future.transaction import ApplicationNoOpTxn, PaymentTxn, AssetTransferTxn from tinyman.utils import TransactionGroup from .contracts import get_pool_logicsig -def prepare_redeem_transactions(validator_app_id, asset1_id, asset2_id, liquidity_asset_id, asset_id, asset_amount, sender, suggested_params): +def prepare_redeem_transactions( + validator_app_id, + asset1_id, + asset2_id, + liquidity_asset_id, + asset_id, + asset_amount, + sender, + suggested_params, +): pool_logicsig = get_pool_logicsig(validator_app_id, asset1_id, asset2_id) pool_address = pool_logicsig.address() @@ -17,15 +23,17 @@ def prepare_redeem_transactions(validator_app_id, asset1_id, asset2_id, liquidit sp=suggested_params, receiver=pool_address, amt=2000, - note='fee', + note="fee", ), ApplicationNoOpTxn( sender=pool_address, sp=suggested_params, index=validator_app_id, - app_args=['redeem'], + app_args=["redeem"], accounts=[sender], - foreign_assets=[asset1_id, liquidity_asset_id] if asset2_id == 0 else [asset1_id, asset2_id, liquidity_asset_id], + foreign_assets=[asset1_id, liquidity_asset_id] + if asset2_id == 0 + else [asset1_id, asset2_id, liquidity_asset_id], ), AssetTransferTxn( sender=pool_address, @@ -33,7 +41,9 @@ def prepare_redeem_transactions(validator_app_id, asset1_id, asset2_id, liquidit receiver=sender, amt=int(asset_amount), index=asset_id, - ) if asset_id != 0 else PaymentTxn( + ) + if asset_id != 0 + else PaymentTxn( sender=pool_address, sp=suggested_params, receiver=sender, @@ -41,5 +51,5 @@ def prepare_redeem_transactions(validator_app_id, asset1_id, asset2_id, liquidit ), ] txn_group = TransactionGroup(txns) - txn_group.sign_with_logicisg(pool_logicsig) + txn_group.sign_with_logicsig(pool_logicsig) return txn_group diff --git a/tinyman/v1/swap.py b/tinyman/v1/swap.py index 0022a59..33bcd21 100644 --- a/tinyman/v1/swap.py +++ b/tinyman/v1/swap.py @@ -1,19 +1,27 @@ -import base64 -from os import name -import algosdk from algosdk.future.transaction import ApplicationNoOpTxn, PaymentTxn, AssetTransferTxn from tinyman.utils import TransactionGroup from .contracts import get_pool_logicsig -def prepare_swap_transactions(validator_app_id, asset1_id, asset2_id, liquidity_asset_id, asset_in_id, asset_in_amount, asset_out_amount, swap_type, sender, suggested_params): +def prepare_swap_transactions( + validator_app_id, + asset1_id, + asset2_id, + liquidity_asset_id, + asset_in_id, + asset_in_amount, + asset_out_amount, + swap_type, + sender, + suggested_params, +): pool_logicsig = get_pool_logicsig(validator_app_id, asset1_id, asset2_id) pool_address = pool_logicsig.address() swap_types = { - 'fixed-input': 'fi', - 'fixed-output': 'fo', + "fixed-input": "fi", + "fixed-output": "fo", } asset_out_id = asset2_id if asset_in_id == asset1_id else asset1_id @@ -24,15 +32,17 @@ def prepare_swap_transactions(validator_app_id, asset1_id, asset2_id, liquidity_ sp=suggested_params, receiver=pool_address, amt=2000, - note='fee', + note="fee", ), ApplicationNoOpTxn( sender=pool_address, sp=suggested_params, index=validator_app_id, - app_args=['swap', swap_types[swap_type]], + app_args=["swap", swap_types[swap_type]], accounts=[sender], - foreign_assets=[asset1_id, liquidity_asset_id] if asset2_id == 0 else [asset1_id, asset2_id, liquidity_asset_id], + foreign_assets=[asset1_id, liquidity_asset_id] + if asset2_id == 0 + else [asset1_id, asset2_id, liquidity_asset_id], ), AssetTransferTxn( sender=sender, @@ -40,7 +50,9 @@ def prepare_swap_transactions(validator_app_id, asset1_id, asset2_id, liquidity_ receiver=pool_address, amt=int(asset_in_amount), index=asset_in_id, - ) if asset_in_id != 0 else PaymentTxn( + ) + if asset_in_id != 0 + else PaymentTxn( sender=sender, sp=suggested_params, receiver=pool_address, @@ -52,7 +64,9 @@ def prepare_swap_transactions(validator_app_id, asset1_id, asset2_id, liquidity_ receiver=sender, amt=int(asset_out_amount), index=asset_out_id, - ) if asset_out_id != 0 else PaymentTxn( + ) + if asset_out_id != 0 + else PaymentTxn( sender=pool_address, sp=suggested_params, receiver=sender, @@ -61,5 +75,5 @@ def prepare_swap_transactions(validator_app_id, asset1_id, asset2_id, liquidity_ ] txn_group = TransactionGroup(txns) - txn_group.sign_with_logicisg(pool_logicsig) - return txn_group \ No newline at end of file + txn_group.sign_with_logicsig(pool_logicsig) + return txn_group diff --git a/tinyman/v1/utils.py b/tinyman/v1/utils.py new file mode 100644 index 0000000..dbad291 --- /dev/null +++ b/tinyman/v1/utils.py @@ -0,0 +1,20 @@ +from base64 import b64decode + + +def get_state_from_account_info(account_info, app_id): + try: + app = [a for a in account_info["apps-local-state"] if a["id"] == app_id][0] + except IndexError: + return {} + try: + app_state = {} + for x in app["key-value"]: + key = b64decode(x["key"]) + if x["value"]["type"] == 1: + value = b64decode(x["value"].get("bytes", "")) + else: + value = x["value"].get("uint", 0) + app_state[key] = value + except KeyError: + return {} + return app_state diff --git a/tinyman/v2/__init__.py b/tinyman/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tinyman/v2/add_liquidity.py b/tinyman/v2/add_liquidity.py new file mode 100644 index 0000000..823395f --- /dev/null +++ b/tinyman/v2/add_liquidity.py @@ -0,0 +1,194 @@ +from typing import Optional + +from algosdk.future.transaction import ( + ApplicationNoOpTxn, + PaymentTxn, + AssetTransferTxn, + SuggestedParams, +) + +from tinyman.utils import TransactionGroup +from .constants import ( + ADD_LIQUIDITY_APP_ARGUMENT, + ADD_LIQUIDITY_FLEXIBLE_MODE_APP_ARGUMENT, + ADD_LIQUIDITY_SINGLE_MODE_APP_ARGUMENT, + ADD_INITIAL_LIQUIDITY_APP_ARGUMENT, +) +from .contracts import get_pool_logicsig + + +def prepare_flexible_add_liquidity_transactions( + validator_app_id: int, + asset_1_id: int, + asset_2_id: int, + pool_token_asset_id: int, + asset_1_amount: int, + asset_2_amount: int, + min_pool_token_asset_amount: int, + sender: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + pool_logicsig = get_pool_logicsig(validator_app_id, asset_1_id, asset_2_id) + pool_address = pool_logicsig.address() + + txns = [ + AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + amt=asset_1_amount, + index=asset_1_id, + ), + AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + amt=asset_2_amount, + index=asset_2_id, + ) + if asset_2_id != 0 + else PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + amt=asset_2_amount, + ), + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=validator_app_id, + app_args=[ + ADD_LIQUIDITY_APP_ARGUMENT, + ADD_LIQUIDITY_FLEXIBLE_MODE_APP_ARGUMENT, + min_pool_token_asset_amount, + ], + foreign_assets=[pool_token_asset_id], + accounts=[pool_address], + ), + ] + + min_fee = suggested_params.min_fee + app_call_fee = min_fee * 3 + txns[-1].fee = app_call_fee + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_single_asset_add_liquidity_transactions( + validator_app_id: int, + asset_1_id: int, + asset_2_id: int, + pool_token_asset_id: int, + min_pool_token_asset_amount: int, + sender: str, + suggested_params: SuggestedParams, + asset_1_amount: Optional[int] = None, + asset_2_amount: Optional[int] = None, +) -> TransactionGroup: + pool_logicsig = get_pool_logicsig(validator_app_id, asset_1_id, asset_2_id) + pool_address = pool_logicsig.address() + + assert bool(asset_1_amount) != bool( + asset_2_amount + ), "If you want to add asset 1 and asset 2 at the same time, please use flexible add liquidity." + + if asset_1_amount: + asset_in_id = asset_1_id + asset_in_amount = asset_1_amount + + elif asset_2_amount: + asset_in_id = asset_2_id + asset_in_amount = asset_2_amount + + else: + assert False + + txns = [ + AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + amt=asset_in_amount, + index=asset_in_id, + ) + if asset_in_id != 0 + else PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + amt=asset_in_amount, + ), + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=validator_app_id, + app_args=[ + ADD_LIQUIDITY_APP_ARGUMENT, + ADD_LIQUIDITY_SINGLE_MODE_APP_ARGUMENT, + min_pool_token_asset_amount, + ], + foreign_assets=[pool_token_asset_id], + accounts=[pool_address], + ), + ] + + min_fee = suggested_params.min_fee + app_call_fee = min_fee * 3 + txns[-1].fee = app_call_fee + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_initial_add_liquidity_transactions( + validator_app_id: int, + asset_1_id: int, + asset_2_id: int, + pool_token_asset_id: int, + asset_1_amount: int, + asset_2_amount: int, + sender: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + pool_logicsig = get_pool_logicsig(validator_app_id, asset_1_id, asset_2_id) + pool_address = pool_logicsig.address() + + txns = [ + AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + amt=asset_1_amount, + index=asset_1_id, + ), + AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + amt=asset_2_amount, + index=asset_2_id, + ) + if asset_2_id != 0 + else PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + amt=asset_2_amount, + ), + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=validator_app_id, + app_args=[ADD_INITIAL_LIQUIDITY_APP_ARGUMENT], + foreign_assets=[pool_token_asset_id], + accounts=[pool_address], + ), + ] + + min_fee = suggested_params.min_fee + app_call_fee = min_fee * 2 + txns[-1].fee = app_call_fee + + txn_group = TransactionGroup(txns) + return txn_group diff --git a/tinyman/v2/bootstrap.py b/tinyman/v2/bootstrap.py new file mode 100644 index 0000000..e8aa153 --- /dev/null +++ b/tinyman/v2/bootstrap.py @@ -0,0 +1,53 @@ +from algosdk.future.transaction import ( + ApplicationOptInTxn, + PaymentTxn, + SuggestedParams, +) +from algosdk.logic import get_application_address + +from tinyman.utils import TransactionGroup +from .constants import BOOTSTRAP_APP_ARGUMENT +from .contracts import get_pool_logicsig + + +def prepare_bootstrap_transactions( + validator_app_id: int, + asset_1_id: int, + asset_2_id: int, + sender: str, + app_call_fee: int, + required_algo: int, + suggested_params: SuggestedParams, +) -> TransactionGroup: + pool_logicsig = get_pool_logicsig(validator_app_id, asset_1_id, asset_2_id) + pool_address = pool_logicsig.address() + assert asset_1_id > asset_2_id + + txns = list() + + # Fund pool account to cover minimum balance and fee requirements + if required_algo: + txns.append( + PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + amt=required_algo, + ) + ) + + # Bootstrap (Opt-in) App Call + bootstrap_app_call = ApplicationOptInTxn( + sender=pool_address, + sp=suggested_params, + index=validator_app_id, + app_args=[BOOTSTRAP_APP_ARGUMENT], + foreign_assets=[asset_1_id, asset_2_id], + rekey_to=get_application_address(validator_app_id), + ) + bootstrap_app_call.fee = app_call_fee + txns.append(bootstrap_app_call) + + txn_group = TransactionGroup(txns) + txn_group.sign_with_logicsig(pool_logicsig) + return txn_group diff --git a/tinyman/v2/client.py b/tinyman/v2/client.py new file mode 100644 index 0000000..94f080f --- /dev/null +++ b/tinyman/v2/client.py @@ -0,0 +1,38 @@ +from algosdk.v2client.algod import AlgodClient +from tinyman.client import BaseTinymanClient +from tinyman.staking.constants import ( + TESTNET_STAKING_APP_ID, + MAINNET_STAKING_APP_ID, +) + +from tinyman.v2.constants import ( + TESTNET_VALIDATOR_APP_ID, + MAINNET_VALIDATOR_APP_ID, +) + + +class TinymanV2Client(BaseTinymanClient): + def fetch_pool(self, asset_a, asset_b, fetch=True): + from .pools import Pool + + return Pool(self, asset_a, asset_b, fetch=fetch) + + +class TinymanV2TestnetClient(TinymanV2Client): + def __init__(self, algod_client: AlgodClient, user_address=None): + super().__init__( + algod_client, + validator_app_id=TESTNET_VALIDATOR_APP_ID, + user_address=user_address, + staking_app_id=TESTNET_STAKING_APP_ID, + ) + + +class TinymanV2MainnetClient(TinymanV2Client): + def __init__(self, algod_client: AlgodClient, user_address=None): + super().__init__( + algod_client, + validator_app_id=MAINNET_VALIDATOR_APP_ID, + user_address=user_address, + staking_app_id=MAINNET_STAKING_APP_ID, + ) diff --git a/tinyman/v2/constants.py b/tinyman/v2/constants.py new file mode 100644 index 0000000..3d84002 --- /dev/null +++ b/tinyman/v2/constants.py @@ -0,0 +1,56 @@ +from algosdk.logic import get_application_address + +POOL_LOGICSIG_TEMPLATE = ( + "BoAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgQBbNQA0ADEYEkQxGYEBEkSBAUM=" +) + +BOOTSTRAP_APP_ARGUMENT = b"bootstrap" +ADD_LIQUIDITY_APP_ARGUMENT = b"add_liquidity" +ADD_INITIAL_LIQUIDITY_APP_ARGUMENT = b"add_initial_liquidity" +REMOVE_LIQUIDITY_APP_ARGUMENT = b"remove_liquidity" +SWAP_APP_ARGUMENT = b"swap" +FLASH_LOAN_APP_ARGUMENT = b"flash_loan" +VERIFY_FLASH_LOAN_APP_ARGUMENT = b"verify_flash_loan" +FLASH_SWAP_APP_ARGUMENT = b"flash_swap" +VERIFY_FLASH_SWAP_APP_ARGUMENT = b"verify_flash_swap" +CLAIM_FEES_APP_ARGUMENT = b"claim_fees" +CLAIM_EXTRA_APP_ARGUMENT = b"claim_extra" +SET_FEE_APP_ARGUMENT = b"set_fee" +SET_FEE_COLLECTOR_APP_ARGUMENT = b"set_fee_collector" +SET_FEE_SETTER_APP_ARGUMENT = b"set_fee_setter" +SET_FEE_MANAGER_APP_ARGUMENT = b"set_fee_manager" + +FIXED_INPUT_APP_ARGUMENT = b"fixed-input" +FIXED_OUTPUT_APP_ARGUMENT = b"fixed-output" + +ADD_LIQUIDITY_FLEXIBLE_MODE_APP_ARGUMENT = b"flexible" +ADD_LIQUIDITY_SINGLE_MODE_APP_ARGUMENT = b"single" + + +TESTNET_VALIDATOR_APP_ID_V2 = 148607000 +MAINNET_VALIDATOR_APP_ID_V2 = None + +TESTNET_VALIDATOR_APP_ID = TESTNET_VALIDATOR_APP_ID_V2 +MAINNET_VALIDATOR_APP_ID = None + +TESTNET_VALIDATOR_APP_ADDRESS = get_application_address(TESTNET_VALIDATOR_APP_ID) +# MAINNET_VALIDATOR_APP_ADDRESS = get_application_address(MAINNET_VALIDATOR_APP_ID) + +LOCKED_POOL_TOKENS = 1000 +ASSET_MIN_TOTAL = 1000000 + +# State +APP_LOCAL_INTS = 12 +APP_LOCAL_BYTES = 2 +APP_GLOBAL_INTS = 0 +APP_GLOBAL_BYTES = 3 + +# 100,000 Algo +# + 100,000 ASA 1 +# + 100,000 ASA 2 +# + 100,000 Pool Token +# + 542,500 App Optin (100000 + (25000+3500)*12 + (25000+25000)*2) +MIN_POOL_BALANCE_ASA_ALGO_PAIR = 300_000 + ( + 100_000 + (25_000 + 3_500) * APP_LOCAL_INTS + (25_000 + 25_000) * APP_LOCAL_BYTES +) +MIN_POOL_BALANCE_ASA_ASA_PAIR = MIN_POOL_BALANCE_ASA_ALGO_PAIR + 100_000 diff --git a/tinyman/v2/contracts.py b/tinyman/v2/contracts.py new file mode 100644 index 0000000..7a2d433 --- /dev/null +++ b/tinyman/v2/contracts.py @@ -0,0 +1,19 @@ +from base64 import b64decode + +from algosdk.future.transaction import LogicSigAccount + +from tinyman.v2.constants import POOL_LOGICSIG_TEMPLATE + + +def get_pool_logicsig( + validator_app_id: int, asset_a_id: int, asset_b_id: int +) -> LogicSigAccount: + assets = [asset_a_id, asset_b_id] + asset_1_id = max(assets) + asset_2_id = min(assets) + + program = bytearray(b64decode(POOL_LOGICSIG_TEMPLATE)) + program[3:11] = validator_app_id.to_bytes(8, "big") + program[11:19] = asset_1_id.to_bytes(8, "big") + program[19:27] = asset_2_id.to_bytes(8, "big") + return LogicSigAccount(program) diff --git a/tinyman/v2/exceptions.py b/tinyman/v2/exceptions.py new file mode 100644 index 0000000..8825ab4 --- /dev/null +++ b/tinyman/v2/exceptions.py @@ -0,0 +1,18 @@ +class PoolIsNotBootstrapped(Exception): + pass + + +class PoolAlreadyBootstrapped(Exception): + pass + + +class PoolHasNoLiquidity(Exception): + pass + + +class PoolAlreadyInitialized(Exception): + pass + + +class InsufficientReserves(Exception): + pass diff --git a/tinyman/v2/fees.py b/tinyman/v2/fees.py new file mode 100644 index 0000000..99e4d30 --- /dev/null +++ b/tinyman/v2/fees.py @@ -0,0 +1,87 @@ +from algosdk.future.transaction import ( + ApplicationNoOpTxn, + SuggestedParams, +) + +from tinyman.utils import TransactionGroup +from tinyman.v2.constants import ( + CLAIM_FEES_APP_ARGUMENT, + CLAIM_EXTRA_APP_ARGUMENT, + SET_FEE_APP_ARGUMENT, +) + + +def prepare_claim_fees_transactions( + validator_app_id: int, + asset_1_id: int, + asset_2_id: int, + pool_address: str, + fee_collector: str, + sender: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + txns = [ + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=validator_app_id, + app_args=[CLAIM_FEES_APP_ARGUMENT], + foreign_assets=[asset_1_id, asset_2_id], + accounts=[pool_address, fee_collector], + ), + ] + + min_fee = suggested_params.min_fee + app_call_fee = min_fee * 3 + txns[-1].fee = app_call_fee + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_claim_extra_transactions( + validator_app_id: int, + asset_id: int, + address: str, + fee_collector: str, + sender: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + txns = [ + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=validator_app_id, + app_args=[CLAIM_EXTRA_APP_ARGUMENT], + foreign_assets=[asset_id], + accounts=[address, fee_collector], + ), + ] + + min_fee = suggested_params.min_fee + app_call_fee = min_fee * 2 + txns[-1].fee = app_call_fee + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_set_fee_transactions( + validator_app_id: int, + pool_address: str, + total_fee_share: int, + protocol_fee_ratio: int, + fee_manager: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + txns = [ + ApplicationNoOpTxn( + sender=fee_manager, + sp=suggested_params, + index=validator_app_id, + app_args=[SET_FEE_APP_ARGUMENT, total_fee_share, protocol_fee_ratio], + accounts=[pool_address], + ), + ] + txn_group = TransactionGroup(txns) + return txn_group diff --git a/tinyman/v2/flash_loan.py b/tinyman/v2/flash_loan.py new file mode 100644 index 0000000..b4df949 --- /dev/null +++ b/tinyman/v2/flash_loan.py @@ -0,0 +1,108 @@ +from algosdk.future.transaction import ( + Transaction, + ApplicationNoOpTxn, + PaymentTxn, + AssetTransferTxn, + SuggestedParams, +) + +from tinyman.utils import TransactionGroup +from .constants import ( + FLASH_LOAN_APP_ARGUMENT, + VERIFY_FLASH_LOAN_APP_ARGUMENT, +) +from .contracts import get_pool_logicsig + + +def prepare_flash_loan_transactions( + validator_app_id: int, + asset_1_id: int, + asset_2_id: int, + asset_1_loan_amount: int, + asset_2_loan_amount: int, + asset_1_payment_amount: int, + asset_2_payment_amount: int, + transactions: "list[Transaction]", + sender: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + assert asset_1_loan_amount or asset_2_loan_amount + + pool_logicsig = get_pool_logicsig(validator_app_id, asset_1_id, asset_2_id) + pool_address = pool_logicsig.address() + min_fee = suggested_params.min_fee + + if asset_1_loan_amount and asset_2_loan_amount: + payment_count = inner_transaction_count = 2 + else: + payment_count = inner_transaction_count = 1 + + index_diff = len(transactions) + payment_count + 1 + txns = [ + # Flash Loan + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=validator_app_id, + app_args=[ + FLASH_LOAN_APP_ARGUMENT, + index_diff, + asset_1_loan_amount, + asset_2_loan_amount, + ], + foreign_assets=[asset_1_id, asset_2_id], + accounts=[pool_address], + ) + ] + # This app call contains inner transactions + txns[0].fee = min_fee * (inner_transaction_count + 1) + + if transactions: + txns.extend(transactions) + + if asset_1_loan_amount: + txns.append( + AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + index=asset_1_id, + amt=asset_1_payment_amount, + ) + ) + + if asset_2_loan_amount: + if asset_2_id: + txns.append( + AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + index=asset_2_id, + amt=asset_2_payment_amount, + ) + ) + else: + txns.append( + PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + amt=asset_2_payment_amount, + ) + ) + + # Verify Flash Loan + txns.append( + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=validator_app_id, + app_args=[VERIFY_FLASH_LOAN_APP_ARGUMENT, index_diff], + foreign_assets=[], + accounts=[pool_address], + ) + ) + + txn_group = TransactionGroup(txns) + return txn_group diff --git a/tinyman/v2/flash_swap.py b/tinyman/v2/flash_swap.py new file mode 100644 index 0000000..b89dbbc --- /dev/null +++ b/tinyman/v2/flash_swap.py @@ -0,0 +1,72 @@ +from algosdk.future.transaction import ( + Transaction, + ApplicationNoOpTxn, + SuggestedParams, +) + +from tinyman.utils import TransactionGroup +from .constants import ( + FLASH_SWAP_APP_ARGUMENT, + VERIFY_FLASH_SWAP_APP_ARGUMENT, +) +from .contracts import get_pool_logicsig + + +def prepare_flash_swap_transactions( + validator_app_id: int, + asset_1_id: int, + asset_2_id: int, + asset_1_loan_amount: int, + asset_2_loan_amount: int, + transactions: "list[Transaction]", + sender: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + assert asset_1_loan_amount or asset_2_loan_amount + + pool_logicsig = get_pool_logicsig(validator_app_id, asset_1_id, asset_2_id) + pool_address = pool_logicsig.address() + min_fee = suggested_params.min_fee + + if asset_1_loan_amount and asset_2_loan_amount: + inner_transaction_count = 2 + else: + inner_transaction_count = 1 + + index_diff = len(transactions) + 1 + txns = [ + # Flash Swap + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=validator_app_id, + app_args=[ + FLASH_SWAP_APP_ARGUMENT, + index_diff, + asset_1_loan_amount, + asset_2_loan_amount, + ], + foreign_assets=[asset_1_id, asset_2_id], + accounts=[pool_address], + ) + ] + # This app call contains inner transactions + txns[0].fee = min_fee * (inner_transaction_count + 1) + + if transactions: + txns.extend(transactions) + + # Verify Flash Swap + txns.append( + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=validator_app_id, + app_args=[VERIFY_FLASH_SWAP_APP_ARGUMENT, index_diff], + foreign_assets=[asset_1_id, asset_2_id], + accounts=[pool_address], + ) + ) + + txn_group = TransactionGroup(txns) + return txn_group diff --git a/tinyman/v2/formulas.py b/tinyman/v2/formulas.py new file mode 100644 index 0000000..258c9df --- /dev/null +++ b/tinyman/v2/formulas.py @@ -0,0 +1,300 @@ +import math + +from tinyman.utils import calculate_price_impact +from tinyman.v2.constants import LOCKED_POOL_TOKENS +from tinyman.v2.exceptions import InsufficientReserves + + +def calculate_protocol_fee_amount( + total_fee_amount: int, protocol_fee_ratio: int +) -> int: + protocol_fee_amount = total_fee_amount // protocol_fee_ratio + return protocol_fee_amount + + +def calculate_poolers_fee_amount(total_fee_amount: int, protocol_fee_ratio: int) -> int: + protocol_fee_amount = calculate_protocol_fee_amount( + total_fee_amount, protocol_fee_ratio + ) + poolers_fee_amount = total_fee_amount - protocol_fee_amount + return poolers_fee_amount + + +def calculate_fixed_input_fee_amount(input_amount: int, total_fee_share: int) -> int: + total_fee_amount = (input_amount * total_fee_share) // 10_000 + return total_fee_amount + + +def calculate_fixed_output_fee_amount(swap_amount: int, total_fee_share: int) -> int: + input_amount = (swap_amount * 10_000) // (10_000 - total_fee_share) + total_fee_amount = input_amount - swap_amount + return total_fee_amount + + +def calculate_internal_swap_fee_amount(swap_amount: int, total_fee_share: int) -> int: + total_fee_amount = int((swap_amount * total_fee_share) / (10_000 - total_fee_share)) + return total_fee_amount + + +def calculate_flash_loan_payment_amount(loan_amount: int, total_fee_share: int) -> int: + total_fee_amount = calculate_fixed_input_fee_amount(loan_amount, total_fee_share) + payment_amount = loan_amount + total_fee_amount + return payment_amount + + +def calculate_flash_swap_asset_2_payment_amount( + asset_1_reserves: int, + asset_2_reserves: int, + total_fee_share: int, + protocol_fee_ratio: int, + asset_1_loan_amount: int, + asset_2_loan_amount: int, + asset_1_payment_amount: int, +) -> int: + k = asset_1_reserves * asset_2_reserves + asset_1_total_fee_amount = calculate_fixed_input_fee_amount( + asset_1_payment_amount, total_fee_share + ) + asset_1_protocol_fee_amount = calculate_protocol_fee_amount( + asset_1_total_fee_amount, protocol_fee_ratio + ) + asset_1_poolers_fee_amount = calculate_poolers_fee_amount( + asset_1_total_fee_amount, protocol_fee_ratio + ) + + final_asset_1_reserves = (asset_1_reserves - asset_1_loan_amount) + ( + asset_1_payment_amount - asset_1_protocol_fee_amount + ) + final_asset_1_reserves_without_poolers_fee = ( + final_asset_1_reserves - asset_1_poolers_fee_amount + ) + minimum_final_asset_2_reserves_without_poolers_fee = ( + k / final_asset_1_reserves_without_poolers_fee + ) + minimum_asset_2_payment_amount_without_poolers_fee = ( + minimum_final_asset_2_reserves_without_poolers_fee + - (asset_2_reserves - asset_2_loan_amount) + ) + minimum_asset_2_payment_amount = math.ceil( + minimum_asset_2_payment_amount_without_poolers_fee + * 10_000 + / (10_000 - total_fee_share) + ) + return minimum_asset_2_payment_amount + + +def calculate_flash_swap_asset_1_payment_amount( + asset_1_reserves: int, + asset_2_reserves: int, + total_fee_share: int, + protocol_fee_ratio: int, + asset_1_loan_amount: int, + asset_2_loan_amount: int, + asset_2_payment_amount: int, +) -> int: + k = asset_1_reserves * asset_2_reserves + asset_2_total_fee_amount = calculate_fixed_input_fee_amount( + asset_2_payment_amount, total_fee_share + ) + asset_2_protocol_fee_amount = calculate_protocol_fee_amount( + asset_2_total_fee_amount, protocol_fee_ratio + ) + asset_2_poolers_fee_amount = calculate_poolers_fee_amount( + asset_2_total_fee_amount, protocol_fee_ratio + ) + + final_asset_2_reserves = (asset_2_reserves - asset_2_loan_amount) + ( + asset_2_payment_amount - asset_2_protocol_fee_amount + ) + final_asset_2_reserves_without_poolers_fee = ( + final_asset_2_reserves - asset_2_poolers_fee_amount + ) + minimum_final_asset_1_reserves_without_poolers_fee = ( + k / final_asset_2_reserves_without_poolers_fee + ) + minimum_asset_1_payment_amount_without_poolers_fee = ( + minimum_final_asset_1_reserves_without_poolers_fee + - (asset_1_reserves - asset_1_loan_amount) + ) + + minimum_asset_1_payment_amount = math.ceil( + minimum_asset_1_payment_amount_without_poolers_fee + * 10_000 + / (10_000 - total_fee_share) + ) + return minimum_asset_1_payment_amount + + +def calculate_initial_add_liquidity(asset_1_amount: int, asset_2_amount: int) -> int: + assert bool(asset_1_amount) and bool( + asset_2_amount + ), "Both assets are required for the initial add liquidity" + + pool_token_asset_amount = ( + int(math.sqrt(asset_1_amount * asset_2_amount)) - LOCKED_POOL_TOKENS + ) + return pool_token_asset_amount + + +def calculate_remove_liquidity_output_amounts( + pool_token_asset_amount: int, + asset_1_reserves: int, + asset_2_reserves: int, + issued_pool_tokens: int, +) -> (int, int): + if issued_pool_tokens > (pool_token_asset_amount + LOCKED_POOL_TOKENS): + asset_1_output_amount = ( + pool_token_asset_amount * asset_1_reserves / issued_pool_tokens + ) + asset_2_output_amount = ( + pool_token_asset_amount * asset_2_reserves / issued_pool_tokens + ) + else: + asset_1_output_amount = asset_1_reserves + asset_2_output_amount = asset_2_reserves + + return int(asset_1_output_amount), int(asset_2_output_amount) + + +def calculate_subsequent_add_liquidity( + asset_1_reserves: int, + asset_2_reserves: int, + issued_pool_tokens: int, + total_fee_share: int, + asset_1_amount: int, + asset_2_amount: int, +) -> (int, bool, int, int, int, float): + assert asset_1_reserves and asset_2_reserves and issued_pool_tokens + + old_k = asset_1_reserves * asset_2_reserves + new_asset_1_reserves = asset_1_reserves + asset_1_amount + new_asset_2_reserves = asset_2_reserves + asset_2_amount + new_k = new_asset_1_reserves * new_asset_2_reserves + new_issued_pool_tokens = int( + math.sqrt(int((new_k * (issued_pool_tokens**2)) / old_k)) + ) + + pool_token_asset_amount = new_issued_pool_tokens - issued_pool_tokens + calculated_asset_1_amount = int( + (pool_token_asset_amount * new_asset_1_reserves) / new_issued_pool_tokens + ) + calculated_asset_2_amount = int( + (pool_token_asset_amount * new_asset_2_reserves) / new_issued_pool_tokens + ) + + asset_1_swap_amount = asset_1_amount - calculated_asset_1_amount + asset_2_swap_amount = asset_2_amount - calculated_asset_2_amount + + if asset_1_swap_amount > asset_2_swap_amount: + swap_in_amount_without_fee = asset_1_swap_amount + swap_out_amount = -min(asset_2_swap_amount, 0) + swap_from_asset_1_to_asset_2 = True + + swap_total_fee_amount = calculate_internal_swap_fee_amount( + swap_in_amount_without_fee, + total_fee_share, + ) + fee_as_pool_tokens = int( + swap_total_fee_amount * new_issued_pool_tokens / (new_asset_1_reserves * 2) + ) + swap_in_amount = swap_in_amount_without_fee + swap_total_fee_amount + pool_token_asset_amount = pool_token_asset_amount - fee_as_pool_tokens + else: + swap_in_amount_without_fee = asset_2_swap_amount + swap_out_amount = -min(asset_1_swap_amount, 0) + swap_from_asset_1_to_asset_2 = False + + swap_total_fee_amount = calculate_internal_swap_fee_amount( + swap_in_amount_without_fee, + total_fee_share, + ) + fee_as_pool_tokens = int( + swap_total_fee_amount * new_issued_pool_tokens / (new_asset_2_reserves * 2) + ) + swap_in_amount = swap_in_amount_without_fee + swap_total_fee_amount + pool_token_asset_amount = pool_token_asset_amount - fee_as_pool_tokens + + swap_price_impact = calculate_price_impact( + input_supply=asset_1_reserves + if swap_from_asset_1_to_asset_2 + else asset_2_reserves, + output_supply=asset_2_reserves + if swap_from_asset_1_to_asset_2 + else asset_1_reserves, + swap_input_amount=swap_in_amount, + swap_output_amount=swap_out_amount, + ) + return ( + pool_token_asset_amount, + swap_from_asset_1_to_asset_2, + swap_in_amount, + swap_out_amount, + swap_total_fee_amount, + swap_price_impact, + ) + + +def calculate_output_amount_of_fixed_input_swap( + input_supply: int, output_supply: int, swap_amount: int +) -> int: + k = input_supply * output_supply + output_amount = output_supply - int(k / (input_supply + swap_amount)) + output_amount -= 1 + return output_amount + + +def calculate_swap_amount_of_fixed_output_swap( + input_supply: int, output_supply: int, output_amount: int +) -> int: + assert output_supply > output_amount + + k = input_supply * output_supply + swap_amount = int(k / (output_supply - output_amount)) - input_supply + swap_amount += 1 + return swap_amount + + +def calculate_fixed_input_swap( + input_supply: int, output_supply: int, swap_input_amount: int, total_fee_share: int +) -> (int, int, int, float): + total_fee_amount = calculate_fixed_input_fee_amount( + input_amount=swap_input_amount, total_fee_share=total_fee_share + ) + swap_amount = swap_input_amount - total_fee_amount + swap_output_amount = calculate_output_amount_of_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, + swap_input_amount=swap_input_amount, + swap_output_amount=swap_output_amount, + ) + return swap_output_amount, total_fee_amount, price_impact + + +def calculate_fixed_output_swap( + input_supply: int, output_supply: int, swap_output_amount: int, total_fee_share: int +) -> (int, int, float): + if output_supply <= swap_output_amount: + raise InsufficientReserves() + + swap_amount = calculate_swap_amount_of_fixed_output_swap( + input_supply, output_supply, swap_output_amount + ) + total_fee_amount = calculate_fixed_output_fee_amount( + swap_amount=swap_amount, total_fee_share=total_fee_share + ) + swap_input_amount = swap_amount + total_fee_amount + + price_impact = calculate_price_impact( + input_supply=input_supply, + output_supply=output_supply, + swap_input_amount=swap_input_amount, + swap_output_amount=swap_output_amount, + ) + return swap_input_amount, total_fee_amount, price_impact diff --git a/tinyman/v2/management.py b/tinyman/v2/management.py new file mode 100644 index 0000000..5892af7 --- /dev/null +++ b/tinyman/v2/management.py @@ -0,0 +1,68 @@ +from algosdk.future.transaction import ( + ApplicationNoOpTxn, + SuggestedParams, +) + +from tinyman.utils import TransactionGroup +from tinyman.v2.constants import ( + SET_FEE_COLLECTOR_APP_ARGUMENT, + SET_FEE_SETTER_APP_ARGUMENT, + SET_FEE_MANAGER_APP_ARGUMENT, +) + + +def prepare_set_fee_collector_transactions( + validator_app_id: int, + fee_manager: str, + new_fee_collector: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + txns = [ + ApplicationNoOpTxn( + sender=fee_manager, + sp=suggested_params, + index=validator_app_id, + app_args=[SET_FEE_COLLECTOR_APP_ARGUMENT], + accounts=[new_fee_collector], + ), + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_set_fee_setter_transactions( + validator_app_id: int, + fee_manager: str, + new_fee_setter: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + txns = [ + ApplicationNoOpTxn( + sender=fee_manager, + sp=suggested_params, + index=validator_app_id, + app_args=[SET_FEE_SETTER_APP_ARGUMENT], + accounts=[new_fee_setter], + ), + ] + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_set_fee_manager_transactions( + validator_app_id: int, + fee_manager: str, + new_fee_manager: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + txns = [ + ApplicationNoOpTxn( + sender=fee_manager, + sp=suggested_params, + index=validator_app_id, + app_args=[SET_FEE_MANAGER_APP_ARGUMENT], + accounts=[new_fee_manager], + ), + ] + txn_group = TransactionGroup(txns) + return txn_group diff --git a/tinyman/v2/pools.py b/tinyman/v2/pools.py new file mode 100644 index 0000000..11eec60 --- /dev/null +++ b/tinyman/v2/pools.py @@ -0,0 +1,1165 @@ +from typing import Optional + +from algosdk.future.transaction import LogicSigAccount, Transaction, SuggestedParams +from algosdk.v2client.algod import AlgodClient + +from tinyman.assets import Asset, AssetAmount +from tinyman.optin import prepare_asset_optin_transactions +from tinyman.utils import TransactionGroup +from .add_liquidity import ( + prepare_initial_add_liquidity_transactions, + prepare_single_asset_add_liquidity_transactions, + prepare_flexible_add_liquidity_transactions, +) +from .bootstrap import prepare_bootstrap_transactions +from .client import TinymanV2Client +from .constants import MIN_POOL_BALANCE_ASA_ALGO_PAIR, MIN_POOL_BALANCE_ASA_ASA_PAIR +from .contracts import get_pool_logicsig +from .exceptions import ( + PoolAlreadyBootstrapped, + PoolIsNotBootstrapped, + PoolHasNoLiquidity, + PoolAlreadyInitialized, +) +from .fees import ( + prepare_claim_fees_transactions, + prepare_set_fee_transactions, +) +from .flash_loan import prepare_flash_loan_transactions +from .formulas import ( + calculate_subsequent_add_liquidity, + calculate_initial_add_liquidity, + calculate_fixed_input_swap, + calculate_remove_liquidity_output_amounts, + calculate_fixed_output_swap, + calculate_flash_loan_payment_amount, + calculate_fixed_input_fee_amount, +) +from .quotes import ( + FlexibleAddLiquidityQuote, + SingleAssetAddLiquidityQuote, + InitialAddLiquidityQuote, + RemoveLiquidityQuote, + InternalSwapQuote, + SingleAssetRemoveLiquidityQuote, + SwapQuote, + FlashLoanQuote, +) +from .remove_liquidity import ( + prepare_remove_liquidity_transactions, + prepare_single_asset_remove_liquidity_transactions, +) +from .swap import prepare_swap_transactions +from .utils import get_state_from_account_info + + +def generate_pool_info(address, validator_app_id, round_number, state): + return { + "address": address, + "validator_app_id": validator_app_id, + "round": round_number, + **state, + } + + +def get_pool_info( + client: AlgodClient, validator_app_id: int, asset_1_id: int, asset_2_id: int +) -> dict: + pool_logicsig = get_pool_logicsig(validator_app_id, asset_1_id, asset_2_id) + pool_address = pool_logicsig.address() + account_info = client.account_info(pool_address) + pool_state = get_pool_state_from_account_info(account_info) + + return generate_pool_info( + address=pool_address, + validator_app_id=validator_app_id, + round_number=account_info.get("round"), + state=pool_state, + ) + + +def get_validator_app_id_from_account_info(account_info: dict) -> Optional[int]: + try: + return account_info["apps-local-state"][0]["id"] + except IndexError: + return None + + +def get_pool_state_from_account_info(account_info: dict) -> dict: + if validator_app_id := get_validator_app_id_from_account_info(account_info): + return get_state_from_account_info(account_info, validator_app_id) + return {} + + +class Pool: + def __init__( + self, + client: TinymanV2Client, + asset_a: [Asset, int], + asset_b: [Asset, int], + info=None, + fetch=True, + validator_app_id=None, + ) -> None: + self.client = client + self.validator_app_id = ( + validator_app_id + if validator_app_id is not None + else client.validator_app_id + ) + + if isinstance(asset_a, int): + if fetch: + asset_a = client.fetch_asset(asset_a) + else: + asset_a = Asset(id=asset_a) + if isinstance(asset_b, int): + if fetch: + asset_b = client.fetch_asset(asset_b) + else: + asset_b = Asset(id=asset_b) + + if asset_a.id > asset_b.id: + self.asset_1 = asset_a + self.asset_2 = asset_b + else: + self.asset_1 = asset_b + self.asset_2 = asset_a + + self.exists = None + self.pool_token_asset: Optional[Asset] = None + self.asset_1_reserves = None + self.asset_2_reserves = None + self.issued_pool_tokens = None + self.asset_1_protocol_fees = None + self.asset_2_protocol_fees = None + self.total_fee_share = None + self.protocol_fee_ratio = None + self.last_refreshed_round = None + + if fetch: + self.refresh() + elif info is not None: + 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}" + + @classmethod + def from_account_info( + cls, + account_info: dict, + client: TinymanV2Client, + fetch: bool = False, + ): + state = get_pool_state_from_account_info(account_info) + validator_app_id = get_validator_app_id_from_account_info(account_info) + assert validator_app_id == client.validator_app_id + + info = generate_pool_info( + address=account_info["address"], + validator_app_id=client.validator_app_id, + round_number=account_info["round"], + state=state, + ) + + pool = Pool( + client=client, + asset_a=info["asset_1_id"], + asset_b=info["asset_2_id"], + info=info, + fetch=fetch, + validator_app_id=info["validator_app_id"], + ) + return pool + + @classmethod + def from_state( + cls, + address: str, + state: dict, + round_number: int, + client: TinymanV2Client, + fetch: bool = False, + ): + assert state + + info = generate_pool_info( + address=address, + validator_app_id=client.validator_app_id, + round_number=round_number, + state=state, + ) + + pool = Pool( + client=client, + asset_a=info["asset_1_id"], + asset_b=info["asset_2_id"], + info=info, + fetch=fetch, + validator_app_id=info["validator_app_id"], + ) + return pool + + def refresh(self, info: Optional[dict] = None) -> None: + if info is None: + info = get_pool_info( + self.client.algod, + self.validator_app_id, + self.asset_1.id, + self.asset_2.id, + ) + self.update_from_info(info) + + def update_from_info(self, info: dict, fetch: bool = True) -> None: + if info.get("pool_token_asset_id"): + self.exists = True + if fetch: + self.pool_token_asset = self.client.fetch_asset( + info["pool_token_asset_id"] + ) + else: + self.pool_token_asset = Asset( + id=info["pool_token_asset_id"], unit_name="TMPOOL2", decimals=6 + ) + + self.asset_1_reserves = info["asset_1_reserves"] + self.asset_2_reserves = info["asset_2_reserves"] + self.issued_pool_tokens = info["issued_pool_tokens"] + self.asset_1_protocol_fees = info["asset_1_protocol_fees"] + self.asset_2_protocol_fees = info["asset_2_protocol_fees"] + self.total_fee_share = info["total_fee_share"] + self.protocol_fee_ratio = info["protocol_fee_ratio"] + self.last_refreshed_round = info["round"] + + def get_logicsig(self) -> LogicSigAccount: + pool_logicsig = get_pool_logicsig( + self.validator_app_id, self.asset_1.id, self.asset_2.id + ) + return pool_logicsig + + @property + def address(self) -> str: + logicsig = self.get_logicsig() + pool_address = logicsig.address() + return pool_address + + @property + def asset_1_price(self) -> float: + if not self.issued_pool_tokens: + raise PoolHasNoLiquidity() + + return self.asset_2_reserves / self.asset_1_reserves + + @property + def asset_2_price(self) -> float: + if not self.issued_pool_tokens: + raise PoolHasNoLiquidity() + + return self.asset_1_reserves / self.asset_2_reserves + + def info(self) -> dict: + if not self.exists: + raise PoolIsNotBootstrapped() + + pool = { + "address": self.address, + "asset_1_id": self.asset_1.id, + "asset_2_id": self.asset_2.id, + "asset_1_unit_name": self.asset_1.unit_name, + "asset_2_unit_name": self.asset_2.unit_name, + "pool_token_asset_id": self.pool_token_asset.id, + "pool_token_asset_name": self.pool_token_asset.name, + "asset_1_reserves": self.asset_1_reserves, + "asset_2_reserves": self.asset_2_reserves, + "issued_pool_tokens": self.issued_pool_tokens, + "asset_1_protocol_fees": self.asset_1_protocol_fees, + "asset_2_protocol_fees": self.asset_2_protocol_fees, + "total_fee_share": self.total_fee_share, + "protocol_fee_ratio": self.protocol_fee_ratio, + "last_refreshed_round": self.last_refreshed_round, + } + return pool + + def convert(self, amount: AssetAmount) -> AssetAmount: + if not self.issued_pool_tokens: + raise PoolHasNoLiquidity() + + if amount.asset == self.asset_1: + return AssetAmount(self.asset_2, int(amount.amount * self.asset_1_price)) + elif amount.asset == self.asset_2: + return AssetAmount(self.asset_1, int(amount.amount * self.asset_2_price)) + + raise NotImplementedError() + + def prepare_pool_token_asset_optin_transactions( + self, + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.client.user_address + + if suggested_params is None: + suggested_params = self.client.algod.suggested_params() + + txn_group = prepare_asset_optin_transactions( + asset_id=self.pool_token_asset.id, + sender=user_address, + suggested_params=suggested_params, + ) + return txn_group + + def fetch_pool_position(self, user_address: Optional[str] = None) -> dict: + user_address = user_address or self.client.user_address + account_info = self.client.algod.account_info(user_address) + assets = {a["asset-id"]: a for a in account_info["assets"]} + pool_token_asset_amount = assets.get(self.pool_token_asset.id, {}).get( + "amount", 0 + ) + quote = self.fetch_remove_liquidity_quote(pool_token_asset_amount) + return { + self.asset_1: quote.amounts_out[self.asset_1], + self.asset_2: quote.amounts_out[self.asset_2], + self.pool_token_asset: quote.pool_token_asset_amount, + "share": (pool_token_asset_amount / self.issued_pool_tokens), + } + + def prepare_bootstrap_transactions( + self, + user_address: Optional[str] = None, + pool_algo_balance=None, + refresh: bool = True, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + + user_address = user_address or self.client.user_address + + if refresh: + self.refresh() + + if self.exists: + raise PoolAlreadyBootstrapped() + + if pool_algo_balance is None: + pool_account_info = self.client.algod.account_info(self.address) + pool_algo_balance = pool_account_info["amount"] + + if suggested_params is None: + suggested_params = self.client.algod.suggested_params() + + if self.asset_2.id == 0: + pool_minimum_balance = MIN_POOL_BALANCE_ASA_ALGO_PAIR + inner_transaction_count = 5 + else: + pool_minimum_balance = MIN_POOL_BALANCE_ASA_ASA_PAIR + inner_transaction_count = 6 + + app_call_fee = (inner_transaction_count + 1) * suggested_params.min_fee + required_algo = pool_minimum_balance + app_call_fee + # to fund minimum balance increase because of asset creation + required_algo += 100_000 + required_algo = max(required_algo - pool_algo_balance, 0) + + txn_group = prepare_bootstrap_transactions( + validator_app_id=self.validator_app_id, + asset_1_id=self.asset_1.id, + asset_2_id=self.asset_2.id, + sender=user_address, + suggested_params=suggested_params, + app_call_fee=app_call_fee, + required_algo=required_algo, + ) + return txn_group + + def fetch_flexible_add_liquidity_quote( + self, + amount_a: AssetAmount, + amount_b: AssetAmount, + slippage: float = 0.05, + refresh: bool = True, + ) -> FlexibleAddLiquidityQuote: + assert {self.asset_1, self.asset_2} == { + amount_a.asset, + amount_b.asset, + }, "Pool assets and given assets don't match." + + amount_1 = amount_a if amount_a.asset == self.asset_1 else amount_b + amount_2 = amount_a if amount_a.asset == self.asset_2 else amount_b + + if refresh: + self.refresh() + + if not self.exists: + raise PoolIsNotBootstrapped() + + if not self.issued_pool_tokens: + raise PoolHasNoLiquidity() + + ( + pool_token_asset_amount, + swap_from_asset_1_to_asset_2, + swap_in_amount, + swap_out_amount, + swap_total_fee_amount, + swap_price_impact, + ) = calculate_subsequent_add_liquidity( + asset_1_reserves=self.asset_1_reserves, + asset_2_reserves=self.asset_2_reserves, + issued_pool_tokens=self.issued_pool_tokens, + total_fee_share=self.total_fee_share, + asset_1_amount=amount_1.amount, + asset_2_amount=amount_2.amount, + ) + + if not swap_out_amount: + # There is no output amount, ignore the integer roundings looks like a swap. + internal_swap_quote = None + else: + internal_swap_quote = InternalSwapQuote( + amount_in=AssetAmount( + self.asset_1 if swap_from_asset_1_to_asset_2 else self.asset_2, + swap_in_amount, + ), + amount_out=AssetAmount( + self.asset_2 if swap_from_asset_1_to_asset_2 else self.asset_1, + swap_out_amount, + ), + swap_fees=AssetAmount( + self.asset_1 if swap_from_asset_1_to_asset_2 else self.asset_2, + swap_total_fee_amount, + ), + price_impact=swap_price_impact, + ) + + quote = FlexibleAddLiquidityQuote( + amounts_in={ + self.asset_1: amount_1, + self.asset_2: amount_2, + }, + pool_token_asset_amount=AssetAmount( + self.pool_token_asset, pool_token_asset_amount + ), + slippage=slippage, + internal_swap_quote=internal_swap_quote, + ) + return quote + + def fetch_single_asset_add_liquidity_quote( + self, amount_a: AssetAmount, slippage: float = 0.05, refresh: bool = True + ) -> SingleAssetAddLiquidityQuote: + if refresh: + self.refresh() + + if not self.exists: + raise PoolIsNotBootstrapped() + + if not self.issued_pool_tokens: + raise PoolHasNoLiquidity() + + if amount_a.asset == self.asset_1: + ( + pool_token_asset_amount, + swap_from_asset_1_to_asset_2, + swap_in_amount, + swap_out_amount, + swap_total_fee_amount, + swap_price_impact, + ) = calculate_subsequent_add_liquidity( + asset_1_reserves=self.asset_1_reserves, + asset_2_reserves=self.asset_2_reserves, + issued_pool_tokens=self.issued_pool_tokens, + total_fee_share=self.total_fee_share, + asset_1_amount=amount_a.amount, + asset_2_amount=0, + ) + elif amount_a.asset == self.asset_2: + ( + pool_token_asset_amount, + swap_from_asset_1_to_asset_2, + swap_in_amount, + swap_out_amount, + swap_total_fee_amount, + swap_price_impact, + ) = calculate_subsequent_add_liquidity( + asset_1_reserves=self.asset_1_reserves, + asset_2_reserves=self.asset_2_reserves, + issued_pool_tokens=self.issued_pool_tokens, + total_fee_share=self.total_fee_share, + asset_1_amount=0, + asset_2_amount=amount_a.amount, + ) + else: + assert False, "Given asset doesn't belong to the pool assets." + + internal_swap_quote = InternalSwapQuote( + amount_in=AssetAmount( + self.asset_1 if swap_from_asset_1_to_asset_2 else self.asset_2, + swap_in_amount, + ), + amount_out=AssetAmount( + self.asset_2 if swap_from_asset_1_to_asset_2 else self.asset_1, + swap_out_amount, + ), + swap_fees=AssetAmount( + self.asset_1 if swap_from_asset_1_to_asset_2 else self.asset_2, + swap_total_fee_amount, + ), + price_impact=swap_price_impact, + ) + quote = SingleAssetAddLiquidityQuote( + amount_in=amount_a, + pool_token_asset_amount=AssetAmount( + self.pool_token_asset, pool_token_asset_amount + ), + slippage=slippage, + internal_swap_quote=internal_swap_quote, + ) + return quote + + def fetch_initial_add_liquidity_quote( + self, + amount_a: AssetAmount, + amount_b: AssetAmount, + refresh: bool = True, + ) -> InitialAddLiquidityQuote: + assert {self.asset_1, self.asset_2} == { + amount_a.asset, + amount_b.asset, + }, "Pool assets and given assets don't match." + + amount_1 = amount_a if amount_a.asset == self.asset_1 else amount_b + amount_2 = amount_a if amount_a.asset == self.asset_2 else amount_b + + if refresh: + self.refresh() + + if not self.exists: + raise PoolIsNotBootstrapped() + + if self.issued_pool_tokens: + raise PoolAlreadyInitialized() + + pool_token_asset_amount = calculate_initial_add_liquidity( + asset_1_amount=amount_1.amount, asset_2_amount=amount_2.amount + ) + quote = InitialAddLiquidityQuote( + amounts_in={ + self.asset_1: amount_1, + self.asset_2: amount_2, + }, + pool_token_asset_amount=AssetAmount( + self.pool_token_asset, pool_token_asset_amount + ), + ) + return quote + + def prepare_flexible_add_liquidity_transactions( + self, + amounts_in: "dict[Asset, AssetAmount]", + min_pool_token_asset_amount: int, + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.client.user_address + asset_1_amount = amounts_in[self.asset_1] + asset_2_amount = amounts_in[self.asset_2] + + if suggested_params is None: + suggested_params = self.client.algod.suggested_params() + + txn_group = prepare_flexible_add_liquidity_transactions( + validator_app_id=self.validator_app_id, + asset_1_id=self.asset_1.id, + asset_2_id=self.asset_2.id, + pool_token_asset_id=self.pool_token_asset.id, + asset_1_amount=asset_1_amount.amount, + asset_2_amount=asset_2_amount.amount, + min_pool_token_asset_amount=min_pool_token_asset_amount, + sender=user_address, + suggested_params=suggested_params, + ) + return txn_group + + def prepare_single_asset_add_liquidity_transactions( + self, + amount_in: AssetAmount, + min_pool_token_asset_amount: Optional[int], + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.client.user_address + + if suggested_params is None: + suggested_params = self.client.algod.suggested_params() + + txn_group = prepare_single_asset_add_liquidity_transactions( + validator_app_id=self.validator_app_id, + asset_1_id=self.asset_1.id, + asset_2_id=self.asset_2.id, + pool_token_asset_id=self.pool_token_asset.id, + asset_1_amount=amount_in.amount + if amount_in.asset == self.asset_1 + else None, + asset_2_amount=amount_in.amount + if amount_in.asset == self.asset_2 + else None, + min_pool_token_asset_amount=min_pool_token_asset_amount, + sender=user_address, + suggested_params=suggested_params, + ) + return txn_group + + def prepare_initial_add_liquidity_transactions( + self, + amounts_in: "dict[Asset, AssetAmount]", + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.client.user_address + asset_1_amount = amounts_in[self.asset_1] + asset_2_amount = amounts_in[self.asset_2] + + if suggested_params is None: + suggested_params = self.client.algod.suggested_params() + + txn_group = prepare_initial_add_liquidity_transactions( + validator_app_id=self.validator_app_id, + asset_1_id=self.asset_1.id, + asset_2_id=self.asset_2.id, + pool_token_asset_id=self.pool_token_asset.id, + asset_1_amount=asset_1_amount.amount, + asset_2_amount=asset_2_amount.amount, + sender=user_address, + suggested_params=suggested_params, + ) + return txn_group + + def prepare_add_liquidity_transactions_from_quote( + self, + quote: [ + FlexibleAddLiquidityQuote, + SingleAssetAddLiquidityQuote, + InitialAddLiquidityQuote, + ], + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + if isinstance(quote, FlexibleAddLiquidityQuote): + return self.prepare_flexible_add_liquidity_transactions( + amounts_in=quote.amounts_in, + min_pool_token_asset_amount=quote.min_pool_token_asset_amount_with_slippage, + user_address=user_address, + suggested_params=suggested_params, + ) + elif isinstance(quote, SingleAssetAddLiquidityQuote): + return self.prepare_single_asset_add_liquidity_transactions( + amount_in=quote.amount_in, + min_pool_token_asset_amount=quote.min_pool_token_asset_amount_with_slippage, + user_address=user_address, + suggested_params=suggested_params, + ) + elif isinstance(quote, InitialAddLiquidityQuote): + return self.prepare_initial_add_liquidity_transactions( + amounts_in=quote.amounts_in, + user_address=user_address, + suggested_params=suggested_params, + ) + + raise Exception(f"Invalid quote type({type(quote)})") + + def fetch_remove_liquidity_quote( + self, + pool_token_asset_in: [AssetAmount, int], + slippage: float = 0.05, + refresh: bool = True, + ) -> RemoveLiquidityQuote: + if not self.exists: + raise PoolIsNotBootstrapped() + + if isinstance(pool_token_asset_in, int): + pool_token_asset_in = AssetAmount( + self.pool_token_asset, pool_token_asset_in + ) + + if refresh: + self.refresh() + + ( + asset_1_output_amount, + asset_2_output_amount, + ) = calculate_remove_liquidity_output_amounts( + pool_token_asset_amount=pool_token_asset_in.amount, + asset_1_reserves=self.asset_1_reserves, + asset_2_reserves=self.asset_2_reserves, + issued_pool_tokens=self.issued_pool_tokens, + ) + quote = RemoveLiquidityQuote( + amounts_out={ + self.asset_1: AssetAmount(self.asset_1, asset_1_output_amount), + self.asset_2: AssetAmount(self.asset_2, asset_2_output_amount), + }, + pool_token_asset_amount=pool_token_asset_in, + slippage=slippage, + ) + return quote + + def fetch_single_asset_remove_liquidity_quote( + self, + pool_token_asset_in: [AssetAmount, int], + output_asset: Asset, + slippage: float = 0.05, + refresh: bool = True, + ) -> SingleAssetRemoveLiquidityQuote: + if not self.exists: + raise PoolIsNotBootstrapped() + + if isinstance(pool_token_asset_in, int): + pool_token_asset_in = AssetAmount( + self.pool_token_asset, pool_token_asset_in + ) + + if refresh: + self.refresh() + + ( + asset_1_output_amount, + asset_2_output_amount, + ) = calculate_remove_liquidity_output_amounts( + pool_token_asset_amount=pool_token_asset_in.amount, + asset_1_reserves=self.asset_1_reserves, + asset_2_reserves=self.asset_2_reserves, + issued_pool_tokens=self.issued_pool_tokens, + ) + + if output_asset == self.asset_1: + ( + swap_output_amount, + total_fee_amount, + price_impact, + ) = calculate_fixed_input_swap( + input_supply=self.asset_2_reserves - asset_2_output_amount, + output_supply=self.asset_1_reserves - asset_1_output_amount, + swap_input_amount=asset_2_output_amount, + total_fee_share=self.total_fee_share, + ) + internal_swap_quote = InternalSwapQuote( + amount_in=AssetAmount(self.asset_2, asset_2_output_amount), + amount_out=AssetAmount(self.asset_1, swap_output_amount), + swap_fees=AssetAmount(self.asset_2, total_fee_amount), + price_impact=price_impact, + ) + quote = SingleAssetRemoveLiquidityQuote( + amount_out=AssetAmount( + self.asset_1, asset_1_output_amount + swap_output_amount + ), + pool_token_asset_amount=pool_token_asset_in, + slippage=slippage, + internal_swap_quote=internal_swap_quote, + ) + elif output_asset == self.asset_2: + ( + swap_output_amount, + total_fee_amount, + price_impact, + ) = calculate_fixed_input_swap( + input_supply=self.asset_1_reserves - asset_1_output_amount, + output_supply=self.asset_2_reserves - asset_2_output_amount, + swap_input_amount=asset_1_output_amount, + total_fee_share=self.total_fee_share, + ) + internal_swap_quote = InternalSwapQuote( + amount_in=AssetAmount(self.asset_1, asset_1_output_amount), + amount_out=AssetAmount(self.asset_2, swap_output_amount), + swap_fees=AssetAmount(self.asset_1, total_fee_amount), + price_impact=price_impact, + ) + quote = SingleAssetRemoveLiquidityQuote( + amount_out=AssetAmount( + self.asset_2, asset_2_output_amount + swap_output_amount + ), + pool_token_asset_amount=pool_token_asset_in, + slippage=slippage, + internal_swap_quote=internal_swap_quote, + ) + else: + assert False + + return quote + + def prepare_remove_liquidity_transactions( + self, + pool_token_asset_amount: [AssetAmount, int], + amounts_out: "dict[Asset, AssetAmount]", + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + if isinstance(pool_token_asset_amount, int): + pool_token_asset_amount = AssetAmount( + self.pool_token_asset, pool_token_asset_amount + ) + + user_address = user_address or self.client.user_address + asset_1_amount = amounts_out[self.asset_1] + asset_2_amount = amounts_out[self.asset_2] + + if suggested_params is None: + suggested_params = self.client.algod.suggested_params() + + txn_group = prepare_remove_liquidity_transactions( + validator_app_id=self.validator_app_id, + asset_1_id=self.asset_1.id, + asset_2_id=self.asset_2.id, + pool_token_asset_id=self.pool_token_asset.id, + min_asset_1_amount=asset_1_amount.amount, + min_asset_2_amount=asset_2_amount.amount, + pool_token_asset_amount=pool_token_asset_amount.amount, + sender=user_address, + suggested_params=suggested_params, + ) + return txn_group + + def prepare_single_asset_remove_liquidity_transactions( + self, + pool_token_asset_amount: [AssetAmount, int], + amount_out: AssetAmount, + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + if isinstance(pool_token_asset_amount, int): + pool_token_asset_amount = AssetAmount( + self.pool_token_asset, pool_token_asset_amount + ) + + user_address = user_address or self.client.user_address + + if suggested_params is None: + suggested_params = self.client.algod.suggested_params() + + txn_group = prepare_single_asset_remove_liquidity_transactions( + validator_app_id=self.validator_app_id, + asset_1_id=self.asset_1.id, + asset_2_id=self.asset_2.id, + pool_token_asset_id=self.pool_token_asset.id, + output_asset_id=amount_out.asset.id, + min_output_asset_amount=amount_out.amount, + pool_token_asset_amount=pool_token_asset_amount.amount, + sender=user_address, + suggested_params=suggested_params, + ) + return txn_group + + def prepare_remove_liquidity_transactions_from_quote( + self, + quote: [RemoveLiquidityQuote, SingleAssetRemoveLiquidityQuote], + user_address: Optional[str] = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.client.user_address + + if isinstance(quote, SingleAssetRemoveLiquidityQuote): + return self.prepare_single_asset_remove_liquidity_transactions( + pool_token_asset_amount=quote.pool_token_asset_amount, + amount_out=quote.amount_out_with_slippage, + user_address=user_address, + suggested_params=suggested_params, + ) + elif isinstance(quote, RemoveLiquidityQuote): + return self.prepare_remove_liquidity_transactions( + pool_token_asset_amount=quote.pool_token_asset_amount, + amounts_out={ + self.asset_1: quote.amounts_out_with_slippage[self.asset_1], + self.asset_2: quote.amounts_out_with_slippage[self.asset_2], + }, + user_address=user_address, + suggested_params=suggested_params, + ) + + raise NotImplementedError() + + def fetch_fixed_input_swap_quote( + self, amount_in: AssetAmount, slippage: float = 0.05, refresh: bool = True + ) -> SwapQuote: + if refresh: + self.refresh() + + if not self.exists: + raise PoolIsNotBootstrapped() + + if not self.issued_pool_tokens: + raise PoolHasNoLiquidity() + + if amount_in.asset == self.asset_1: + asset_out = self.asset_2 + input_supply = self.asset_1_reserves + output_supply = self.asset_2_reserves + elif amount_in.asset == self.asset_2: + asset_out = self.asset_1 + input_supply = self.asset_2_reserves + output_supply = self.asset_1_reserves + else: + assert False + + swap_output_amount, total_fee_amount, price_impact = calculate_fixed_input_swap( + input_supply=input_supply, + output_supply=output_supply, + swap_input_amount=amount_in.amount, + total_fee_share=self.total_fee_share, + ) + amount_out = AssetAmount(asset_out, swap_output_amount) + + quote = SwapQuote( + swap_type="fixed-input", + amount_in=amount_in, + amount_out=amount_out, + swap_fees=AssetAmount(amount_in.asset, total_fee_amount), + slippage=slippage, + price_impact=price_impact, + ) + return quote + + def fetch_fixed_output_swap_quote( + self, amount_out: AssetAmount, slippage: float = 0.05, refresh: bool = True + ) -> SwapQuote: + if refresh: + self.refresh() + + if not self.exists: + raise PoolIsNotBootstrapped() + + if not self.issued_pool_tokens: + raise PoolHasNoLiquidity() + + if amount_out.asset == self.asset_1: + asset_in = self.asset_2 + input_supply = self.asset_2_reserves + output_supply = self.asset_1_reserves + elif amount_out.asset == self.asset_2: + asset_in = self.asset_1 + input_supply = self.asset_1_reserves + output_supply = self.asset_2_reserves + else: + assert False + + swap_input_amount, total_fee_amount, price_impact = calculate_fixed_output_swap( + input_supply=input_supply, + output_supply=output_supply, + swap_output_amount=amount_out.amount, + total_fee_share=self.total_fee_share, + ) + amount_in = AssetAmount(asset_in, swap_input_amount) + + quote = SwapQuote( + swap_type="fixed-output", + amount_out=amount_out, + amount_in=amount_in, + swap_fees=AssetAmount(amount_in.asset, total_fee_amount), + slippage=slippage, + price_impact=price_impact, + ) + return quote + + def prepare_swap_transactions( + self, + amount_in: AssetAmount, + amount_out: AssetAmount, + swap_type: [str, bytes], + user_address: str = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.client.user_address + + if suggested_params is None: + suggested_params = self.client.algod.suggested_params() + + txn_group = prepare_swap_transactions( + validator_app_id=self.validator_app_id, + asset_1_id=self.asset_1.id, + asset_2_id=self.asset_2.id, + asset_in_id=amount_in.asset.id, + asset_in_amount=amount_in.amount, + asset_out_amount=amount_out.amount, + swap_type=swap_type, + sender=user_address, + suggested_params=suggested_params, + ) + return txn_group + + def prepare_swap_transactions_from_quote( + self, + quote: SwapQuote, + user_address: str = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + return self.prepare_swap_transactions( + amount_in=quote.amount_in_with_slippage, + amount_out=quote.amount_out_with_slippage, + swap_type=quote.swap_type, + user_address=user_address, + suggested_params=suggested_params, + ) + + def fetch_flash_loan_quote( + self, + loan_amount_a: AssetAmount, + loan_amount_b: AssetAmount, + refresh: bool = True, + ) -> FlashLoanQuote: + assert {self.asset_1, self.asset_2} == { + loan_amount_a.asset, + loan_amount_b.asset, + }, "Pool assets and given assets don't match." + + if loan_amount_a.asset == self.asset_1: + loan_amount_1 = loan_amount_a + else: + loan_amount_1 = loan_amount_b + + if loan_amount_a.asset == self.asset_2: + loan_amount_2 = loan_amount_a + else: + loan_amount_2 = loan_amount_b + + if refresh: + self.refresh() + + if not self.exists: + raise PoolIsNotBootstrapped() + + if not self.issued_pool_tokens: + raise PoolHasNoLiquidity() + + if loan_amount_1.amount > self.asset_1_reserves: + raise Exception( + f"The loan amount({loan_amount_1.amount}) cannot exceed the reserves." + ) + + if loan_amount_2.amount > self.asset_2_reserves: + raise Exception( + f"The loan amount({loan_amount_2.amount}) cannot exceed the reserves." + ) + + quote = FlashLoanQuote( + amounts_out={ + self.asset_1: loan_amount_1, + self.asset_2: loan_amount_2, + }, + amounts_in={ + self.asset_1: AssetAmount( + self.asset_1, + amount=calculate_flash_loan_payment_amount( + loan_amount=loan_amount_1.amount, + total_fee_share=self.total_fee_share, + ), + ), + self.asset_2: AssetAmount( + self.asset_2, + amount=calculate_flash_loan_payment_amount( + loan_amount=loan_amount_2.amount, + total_fee_share=self.total_fee_share, + ), + ), + }, + fees={ + self.asset_1: AssetAmount( + self.asset_1, + amount=calculate_fixed_input_fee_amount( + input_amount=loan_amount_1.amount, + total_fee_share=self.total_fee_share, + ), + ), + self.asset_2: AssetAmount( + self.asset_2, + amount=calculate_fixed_input_fee_amount( + input_amount=loan_amount_2.amount, + total_fee_share=self.total_fee_share, + ), + ), + }, + ) + return quote + + def prepare_flash_loan_transactions( + self, + amounts_out: "dict[Asset, AssetAmount]", + amounts_in: "dict[Asset, AssetAmount]", + transactions: "list[Transaction]", + user_address: str = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.client.user_address + + if suggested_params is None: + suggested_params = self.client.algod.suggested_params() + + txn_group = prepare_flash_loan_transactions( + validator_app_id=self.validator_app_id, + asset_1_id=self.asset_1.id, + asset_2_id=self.asset_2.id, + asset_1_loan_amount=amounts_out[self.asset_1].amount, + asset_2_loan_amount=amounts_out[self.asset_2].amount, + asset_1_payment_amount=amounts_in[self.asset_1].amount, + asset_2_payment_amount=amounts_in[self.asset_2].amount, + transactions=transactions, + sender=user_address, + suggested_params=suggested_params, + ) + return txn_group + + def prepare_flash_loan_transactions_from_quote( + self, + quote: FlashLoanQuote, + transactions: "list[Transaction]", + user_address: str = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.client.user_address + + return self.prepare_flash_loan_transactions( + amounts_out=quote.amounts_out, + amounts_in=quote.amounts_in, + transactions=transactions, + user_address=user_address, + suggested_params=suggested_params, + ) + + def prepare_claim_fees_transactions( + self, + fee_collector: str, + user_address: str = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.client.user_address + + if suggested_params is None: + suggested_params = self.client.algod.suggested_params() + + return prepare_claim_fees_transactions( + validator_app_id=self.validator_app_id, + asset_1_id=self.asset_1.id, + asset_2_id=self.asset_2.id, + pool_address=self.address, + fee_collector=fee_collector, + sender=user_address, + suggested_params=suggested_params, + ) + + def prepare_set_fee_transactions( + self, + total_fee_share: int, + protocol_fee_ratio: int, + user_address: str = None, + suggested_params: SuggestedParams = None, + ) -> TransactionGroup: + user_address = user_address or self.client.user_address + + if suggested_params is None: + suggested_params = self.client.algod.suggested_params() + + return prepare_set_fee_transactions( + validator_app_id=self.validator_app_id, + pool_address=self.address, + total_fee_share=total_fee_share, + protocol_fee_ratio=protocol_fee_ratio, + fee_manager=user_address, + suggested_params=suggested_params, + ) diff --git a/tinyman/v2/quotes.py b/tinyman/v2/quotes.py new file mode 100644 index 0000000..d0edd41 --- /dev/null +++ b/tinyman/v2/quotes.py @@ -0,0 +1,129 @@ +import math +from dataclasses import dataclass + +from tinyman.assets import AssetAmount, Asset + + +@dataclass +class SwapQuote: + swap_type: str + amount_in: AssetAmount + amount_out: AssetAmount + swap_fees: AssetAmount + slippage: float + price_impact: float + + @property + def amount_out_with_slippage(self) -> AssetAmount: + if self.swap_type == "fixed-output": + return self.amount_out + + amount_with_slippage = self.amount_out.amount - int( + self.amount_out.amount * self.slippage + ) + return AssetAmount(self.amount_out.asset, amount_with_slippage) + + @property + def amount_in_with_slippage(self) -> AssetAmount: + if self.swap_type == "fixed-input": + return self.amount_in + + amount_with_slippage = self.amount_in.amount + int( + self.amount_in.amount * self.slippage + ) + return AssetAmount(self.amount_in.asset, amount_with_slippage) + + @property + def price(self) -> float: + return self.amount_out.amount / self.amount_in.amount + + @property + def price_with_slippage(self) -> float: + return ( + self.amount_out_with_slippage.amount / self.amount_in_with_slippage.amount + ) + + +@dataclass +class InternalSwapQuote: + amount_in: AssetAmount + amount_out: AssetAmount + swap_fees: AssetAmount + price_impact: float + + @property + def price(self) -> float: + return self.amount_out.amount / self.amount_in.amount + + +@dataclass +class FlexibleAddLiquidityQuote: + amounts_in: "dict[Asset, AssetAmount]" + pool_token_asset_amount: AssetAmount + slippage: float + internal_swap_quote: InternalSwapQuote = None + + @property + def min_pool_token_asset_amount_with_slippage(self) -> int: + return self.pool_token_asset_amount.amount - math.ceil( + self.pool_token_asset_amount.amount * self.slippage + ) + + +@dataclass +class SingleAssetAddLiquidityQuote: + amount_in: AssetAmount + pool_token_asset_amount: AssetAmount + slippage: float + internal_swap_quote: InternalSwapQuote = None + + @property + def min_pool_token_asset_amount_with_slippage(self) -> int: + return self.pool_token_asset_amount.amount - math.ceil( + self.pool_token_asset_amount.amount * self.slippage + ) + + +@dataclass +class InitialAddLiquidityQuote: + amounts_in: "dict[Asset, AssetAmount]" + pool_token_asset_amount: AssetAmount + + +@dataclass +class RemoveLiquidityQuote: + amounts_out: "dict[Asset, AssetAmount]" + pool_token_asset_amount: AssetAmount + slippage: float + + @property + def amounts_out_with_slippage(self) -> "dict[Asset, AssetAmount]": + amounts_out = {} + for asset, asset_amount in self.amounts_out.items(): + amount_with_slippage = asset_amount.amount - int( + (asset_amount.amount * self.slippage) + ) + amounts_out[asset] = AssetAmount(asset, amount_with_slippage) + return amounts_out + + +@dataclass +class SingleAssetRemoveLiquidityQuote: + amount_out: AssetAmount + pool_token_asset_amount: AssetAmount + slippage: float + internal_swap_quote: InternalSwapQuote = None + + @property + def amount_out_with_slippage(self) -> AssetAmount: + amount_with_slippage = self.amount_out.amount - int( + self.amount_out.amount * self.slippage + ) + return AssetAmount(self.amount_out.asset, amount_with_slippage) + + +@dataclass +class FlashLoanQuote: + amounts_out: "dict[Asset, AssetAmount]" + amounts_in: "dict[Asset, AssetAmount]" + fees: "dict[Asset, AssetAmount]" diff --git a/tinyman/v2/remove_liquidity.py b/tinyman/v2/remove_liquidity.py new file mode 100644 index 0000000..0149fe3 --- /dev/null +++ b/tinyman/v2/remove_liquidity.py @@ -0,0 +1,108 @@ +from algosdk.future.transaction import ( + ApplicationNoOpTxn, + AssetTransferTxn, + SuggestedParams, +) + +from tinyman.utils import TransactionGroup +from .constants import REMOVE_LIQUIDITY_APP_ARGUMENT +from .contracts import get_pool_logicsig + + +def prepare_remove_liquidity_transactions( + validator_app_id: int, + asset_1_id: int, + asset_2_id: int, + pool_token_asset_id: int, + min_asset_1_amount: int, + min_asset_2_amount: int, + pool_token_asset_amount: int, + sender: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + pool_logicsig = get_pool_logicsig(validator_app_id, asset_1_id, asset_2_id) + pool_address = pool_logicsig.address() + + txns = [ + AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + index=pool_token_asset_id, + amt=pool_token_asset_amount, + ), + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=validator_app_id, + app_args=[ + REMOVE_LIQUIDITY_APP_ARGUMENT, + min_asset_1_amount, + min_asset_2_amount, + ], + foreign_assets=[asset_1_id, asset_2_id], + accounts=[pool_address], + ), + ] + + # App call contains 2 inner transactions + min_fee = suggested_params.min_fee + app_call_fee = min_fee * 3 + txns[-1].fee = app_call_fee + + txn_group = TransactionGroup(txns) + return txn_group + + +def prepare_single_asset_remove_liquidity_transactions( + validator_app_id: int, + asset_1_id: int, + asset_2_id: int, + pool_token_asset_id: int, + output_asset_id: int, + min_output_asset_amount: int, + pool_token_asset_amount: int, + sender: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + pool_logicsig = get_pool_logicsig(validator_app_id, asset_1_id, asset_2_id) + pool_address = pool_logicsig.address() + + if output_asset_id == asset_1_id: + min_asset_1_amount = min_output_asset_amount + min_asset_2_amount = 0 + elif output_asset_id == asset_2_id: + min_asset_1_amount = 0 + min_asset_2_amount = min_output_asset_amount + else: + assert False + + txns = [ + AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + index=pool_token_asset_id, + amt=pool_token_asset_amount, + ), + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=validator_app_id, + app_args=[ + REMOVE_LIQUIDITY_APP_ARGUMENT, + min_asset_1_amount, + min_asset_2_amount, + ], + foreign_assets=[output_asset_id], + accounts=[pool_address], + ), + ] + + # App call contains 2 inner transactions + min_fee = suggested_params.min_fee + app_call_fee = min_fee * 3 + txns[-1].fee = app_call_fee + + txn_group = TransactionGroup(txns) + return txn_group diff --git a/tinyman/v2/swap.py b/tinyman/v2/swap.py new file mode 100644 index 0000000..dabc6c8 --- /dev/null +++ b/tinyman/v2/swap.py @@ -0,0 +1,75 @@ +from algosdk.future.transaction import ( + ApplicationNoOpTxn, + PaymentTxn, + AssetTransferTxn, + SuggestedParams, +) + +from tinyman.utils import TransactionGroup +from .constants import ( + SWAP_APP_ARGUMENT, + FIXED_INPUT_APP_ARGUMENT, + FIXED_OUTPUT_APP_ARGUMENT, +) +from .contracts import get_pool_logicsig + + +def prepare_swap_transactions( + validator_app_id: int, + asset_1_id: int, + asset_2_id: int, + asset_in_id: int, + asset_in_amount: int, + asset_out_amount: int, + swap_type: [str, bytes], + sender: str, + suggested_params: SuggestedParams, +) -> TransactionGroup: + pool_logicsig = get_pool_logicsig(validator_app_id, asset_1_id, asset_2_id) + pool_address = pool_logicsig.address() + + txns = [ + AssetTransferTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + index=asset_in_id, + amt=asset_in_amount, + ) + if asset_in_id != 0 + else PaymentTxn( + sender=sender, + sp=suggested_params, + receiver=pool_address, + amt=asset_in_amount, + ), + ApplicationNoOpTxn( + sender=sender, + sp=suggested_params, + index=validator_app_id, + app_args=[SWAP_APP_ARGUMENT, swap_type, asset_out_amount], + foreign_assets=[asset_1_id, asset_2_id], + accounts=[pool_address], + ), + ] + + 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: + # App call contains 1 inner transaction + app_call_fee = min_fee * 2 + elif swap_type == FIXED_OUTPUT_APP_ARGUMENT: + # App call contains 2 inner transactions + app_call_fee = min_fee * 3 + else: + raise NotImplementedError() + + txns[-1].fee = app_call_fee + txn_group = TransactionGroup(txns) + return txn_group diff --git a/tinyman/v2/utils.py b/tinyman/v2/utils.py new file mode 100644 index 0000000..8a652c2 --- /dev/null +++ b/tinyman/v2/utils.py @@ -0,0 +1,37 @@ +from base64 import b64decode + +from tinyman.utils import bytes_to_int + + +def decode_logs(logs: "list[[bytes, str]]") -> dict: + decoded_logs = dict() + for log in logs: + if type(log) == str: + log = b64decode(log.encode()) + if b"%i" in log: + i = log.index(b"%i") + s = log[0:i].decode() + value = int.from_bytes(log[i + 2 :], "big") + decoded_logs[s] = value + else: + raise NotImplementedError() + return decoded_logs + + +def get_state_from_account_info(account_info, app_id): + try: + app = [a for a in account_info["apps-local-state"] if a["id"] == app_id][0] + except IndexError: + return {} + try: + app_state = {} + for x in app["key-value"]: + key = b64decode(x["key"]).decode() + if x["value"]["type"] == 1: + value = bytes_to_int(b64decode(x["value"].get("bytes", ""))) + else: + value = x["value"].get("uint", 0) + app_state[key] = value + except KeyError: + return {} + return app_state