diff --git a/proxy/__main__.py b/proxy/__main__.py index 18c95ec59..9d9987437 100644 --- a/proxy/__main__.py +++ b/proxy/__main__.py @@ -21,13 +21,15 @@ evm_loader_id = os.environ['EVM_LOADER'] faucet_url = os.environ['FAUCET_URL'] wrapper_whitelist = os.environ['INDEXER_ERC20_WRAPPER_WHITELIST'].split(',') - airdrop_amount = int(os.environ['AIRDROP_AMOUNT']) log_level = os.environ['LOG_LEVEL'] + price_update_interval = int(os.environ.get('PRICE_UPDATE_INTERVAL', '60')) + neon_decimals = int(os.environ.get('NEON_DECIMALS', '9')) run_airdropper(solana_url, evm_loader_id, faucet_url, wrapper_whitelist, - airdrop_amount, - log_level) + log_level, + price_update_interval, + neon_decimals) else: entry_point() diff --git a/proxy/indexer/airdropper.py b/proxy/indexer/airdropper.py index 41c638cc6..96472da10 100644 --- a/proxy/indexer/airdropper.py +++ b/proxy/indexer/airdropper.py @@ -1,4 +1,5 @@ from proxy.indexer.indexer_base import IndexerBase, logger +from proxy.indexer.price_provider import PriceProvider, mainnet_solana, mainnet_price_accounts import os import requests import base58 @@ -12,23 +13,34 @@ from .utils import check_error from .sql_dict import SQLDict +ACCOUNT_CREATION_PRICE_SOL = 0.00472692 +AIRDROP_AMOUNT_SOL = ACCOUNT_CREATION_PRICE_SOL / 2 +NEON_PRICE_USD = 0.25 + class Airdropper(IndexerBase): def __init__(self, solana_url, evm_loader_id, faucet_url = '', wrapper_whitelist = [], - airdrop_amount = 10, - log_level = 'INFO'): + log_level = 'INFO', + price_upd_interval=60, + neon_decimals = 9): IndexerBase.__init__(self, solana_url, evm_loader_id, log_level) # collection of eth-address-to-create-accout-trx mappings # for every addresses that was already funded with airdrop self.airdrop_ready = SQLDict(tablename="airdrop_ready") self.wrapper_whitelist = wrapper_whitelist - self.airdrop_amount = airdrop_amount self.faucet_url = faucet_url + # Price provider need pyth.network be deployed onto solana + # so using mainnet solana for simplicity + self.price_provider = PriceProvider(mainnet_solana, + price_upd_interval, + mainnet_price_accounts) + self.neon_decimals = neon_decimals + # helper function checking if given contract address is in whitelist def _is_allowed_wrapper_contract(self, contract_addr): @@ -59,18 +71,28 @@ def _check_transfer(self, account_keys, create_token_acc, token_transfer) -> boo def _airdrop_to(self, create_acc): eth_address = "0x" + bytearray(base58.b58decode(create_acc['data'])[20:][:20]).hex() - if eth_address in self.airdrop_ready: # transaction already processed return - logger.info(f"Airdrop to address: {eth_address}") + sol_price_usd = self.price_provider.get_price('SOL/USD') + if sol_price_usd is None: + logger.warning("Failed to get SOL/USD price") + return + + logger.info(f'SOL/USD = ${sol_price_usd}') + airdrop_amount_usd = AIRDROP_AMOUNT_SOL * sol_price_usd + logger.info(f"Airdrop amount: ${airdrop_amount_usd}") + logger.info(f"NEON price: ${NEON_PRICE_USD}") + airdrop_amount_neon = airdrop_amount_usd / NEON_PRICE_USD + logger.info(f"Airdrop {airdrop_amount_neon} NEONs to address: {eth_address}") + airdrop_galans = int(airdrop_amount_neon * pow(10, self.neon_decimals)) - json_data = { 'wallet': eth_address, 'amount': self.airdrop_amount } - resp = requests.post(self.faucet_url + '/request_eth_token', json = json_data) + json_data = { 'wallet': eth_address, 'amount': airdrop_galans } + resp = requests.post(self.faucet_url + '/request_neon_in_galans', json = json_data) if not resp.ok: logger.warning(f'Failed to airdrop: {resp.status_code}') return - + self.airdrop_ready[eth_address] = create_acc @@ -138,8 +160,9 @@ def run_airdropper(solana_url, evm_loader_id, faucet_url = '', wrapper_whitelist = [], - airdrop_amount = 10, - log_level = 'INFO'): + log_level = 'INFO', + price_update_interval = 60, + neon_decimals = 9): logging.basicConfig(format='%(asctime)s - pid:%(process)d [%(levelname)-.1s] %(funcName)s:%(lineno)d - %(message)s') logger.setLevel(logging.DEBUG) logger.info(f"""Running indexer with params: @@ -148,12 +171,14 @@ def run_airdropper(solana_url, log_level: {log_level}, faucet_url: {faucet_url}, wrapper_whitelist: {wrapper_whitelist}, - airdrop_amount: {airdrop_amount}""") + price update interval: {price_update_interval}, + NEON decimals: {neon_decimals}""") airdropper = Airdropper(solana_url, evm_loader_id, faucet_url, wrapper_whitelist, - airdrop_amount, - log_level) + log_level, + price_update_interval, + neon_decimals) airdropper.run() diff --git a/proxy/indexer/price_provider.py b/proxy/indexer/price_provider.py new file mode 100644 index 000000000..968492812 --- /dev/null +++ b/proxy/indexer/price_provider.py @@ -0,0 +1,117 @@ +from solana.rpc.api import Client +from solana.publickey import PublicKey +import base64 +import base58 +import struct +from datetime import datetime +from logging import Logger + +logger = Logger(__name__) + +field_info = { + 'expo': { 'pos': 20, 'len': 4, 'format': '= self.default_upd_int: + current_price = self._read_price(pairname) + if current_price is not None: + self.prices[pairname] = { 'price': current_price, 'last_update': current_time } + return current_price + + if price_data is not None: + return price_data['price'] + else: + return None + # price_data is not None and current_time - price_data['last_update'] < self.default_upd_int + return price_data['price'] + + diff --git a/proxy/testing/test_airdropper.py b/proxy/testing/test_airdropper.py index 9a00d5279..793b4bbb7 100644 --- a/proxy/testing/test_airdropper.py +++ b/proxy/testing/test_airdropper.py @@ -1,7 +1,8 @@ import unittest from proxy.testing.mock_server import MockServer -from proxy.indexer.airdropper import Airdropper +from proxy.indexer.airdropper import Airdropper, AIRDROP_AMOUNT_SOL, NEON_PRICE_USD from proxy.indexer.sql_dict import SQLDict +from proxy.indexer.price_provider import PriceProvider import time from flask import request, Response from unittest.mock import MagicMock, patch, call, ANY @@ -13,13 +14,13 @@ class MockFaucet(MockServer): def __init__(self, port): super().__init__(port) - self.request_eth_token_mock = MagicMock() - self.request_eth_token_mock.side_effect = itertools.repeat({}) - self.add_url_rule("/request_eth_token", callback=self.request_eth_token, methods=['POST']) + self.request_neon_in_galans_mock = MagicMock() + self.request_neon_in_galans_mock.side_effect = itertools.repeat({}) + self.add_url_rule("/request_neon_in_galans", callback=self.request_neon_in_galans, methods=['POST']) - def request_eth_token(self): + def request_neon_in_galans(self): req = request.get_json() - return self.request_eth_token_mock(req) + return self.request_neon_in_galans_mock(req) def create_signature_for_address(signature: str): @@ -47,44 +48,76 @@ def setUpClass(cls) -> None: print("testing indexer in airdropper mode") cls.address = 'localhost' cls.faucet_port = 3333 - cls.airdrop_amount = 10 - - cls.faucet = MockFaucet(cls.faucet_port) - cls.faucet.start() - time.sleep(0.2) - cls.evm_loader_id = evm_loader_addr cls.wrapper_whitelist = wrapper_whitelist + cls.neon_decimals = 9 cls.airdropper = Airdropper(f'http://{cls.address}:8899', cls.evm_loader_id, f'http://{cls.address}:{cls.faucet_port}', cls.wrapper_whitelist, - cls.airdrop_amount, - 'INFO') + 'INFO', + cls.neon_decimals) - @classmethod - def tearDownClass(cls) -> None: - cls.faucet.shutdown_server() - cls.faucet.join() + def setUp(self) -> None: + print(f"\n\n{self._testMethodName}\n{self._testMethodDoc}") + self.faucet = MockFaucet(self.faucet_port) + self.faucet.start() + time.sleep(0.2) + def tearDown(self) -> None: + self.faucet.shutdown_server() + self.faucet.join() + + + @patch.object(PriceProvider, 'get_price') @patch.object(SQLDict, '__setitem__') @patch.object(SQLDict, '__contains__') def test_success_process_trx_with_one_airdrop(self, mock_sql_dict_contains, - mock_sql_dict_setitem): - print("\n\nShould airdrop to new address - one target in transaction") + mock_sql_dict_setitem, + mock_get_price): + """ + Should airdrop to new address - one target in transaction + """ + sol_price = 341 + airdrop_amount = int(pow(10, self.neon_decimals) * (AIRDROP_AMOUNT_SOL * sol_price) / NEON_PRICE_USD) + mock_get_price.side_effect = [sol_price] mock_sql_dict_contains.side_effect = [False] # new eth address - self.faucet.request_eth_token_mock.side_effect = [Response("{}", status=200, mimetype='application/json')] + self.faucet.request_neon_in_galans_mock.side_effect = [Response("{}", status=200, mimetype='application/json')] self.airdropper.process_trx_airdropper_mode(pre_token_airdrop_trx1) mock_sql_dict_contains.assert_called_once_with(token_airdrop_address1) mock_sql_dict_setitem.assert_has_calls([call(token_airdrop_address1, ANY)]) - json_req = {'wallet': token_airdrop_address1, 'amount': self.airdrop_amount} - self.faucet.request_eth_token_mock.assert_called_once_with(json_req) - self.faucet.request_eth_token_mock.reset_mock() + mock_get_price.assert_called_once_with('SOL/USD') + json_req = {'wallet': token_airdrop_address1, 'amount': airdrop_amount} + self.faucet.request_neon_in_galans_mock.assert_called_once_with(json_req) + self.faucet.request_neon_in_galans_mock.reset_mock() + + + @patch.object(PriceProvider, 'get_price') + @patch.object(SQLDict, '__setitem__') + @patch.object(SQLDict, '__contains__') + def test_failed_process_trx_with_one_airdrop_price_provider_error(self, + mock_sql_dict_contains, + mock_sql_dict_setitem, + mock_get_price): + """ + Should not airdrop to new address due to price provider error + """ + mock_get_price.side_effect = [None] + mock_sql_dict_contains.side_effect = [False] # new eth address + self.faucet.request_neon_in_galans_mock.side_effect = [Response("{}", status=200, mimetype='application/json')] + + self.airdropper.process_trx_airdropper_mode(pre_token_airdrop_trx1) + + mock_sql_dict_contains.assert_called_once_with(token_airdrop_address1) + mock_sql_dict_setitem.assert_not_called() + mock_get_price.assert_called_once_with('SOL/USD') + self.faucet.request_neon_in_galans_mock.assert_not_called() + self.faucet.request_neon_in_galans_mock.reset_mock() @patch.object(Airdropper, '_is_allowed_wrapper_contract') @@ -94,33 +127,43 @@ def test_failed_airdrop_contract_not_in_whitelist(self, mock_sql_dict_contains, mock_sql_dict_setitem, mock_is_allowed_contract): - print("\n\nShould not airdrop for contract that is not in whitelist") + """ + Should not airdrop for contract that is not in whitelist + """ mock_is_allowed_contract.side_effect = [False] self.airdropper.process_trx_airdropper_mode(pre_token_airdrop_trx1) mock_is_allowed_contract.assert_called_once() mock_sql_dict_contains.assert_not_called() mock_sql_dict_setitem.assert_not_called() - self.faucet.request_eth_token_mock.assert_not_called() - self.faucet.request_eth_token_mock.reset_mock() + self.faucet.request_neon_in_galans_mock.assert_not_called() + self.faucet.request_neon_in_galans_mock.reset_mock() + @patch.object(PriceProvider, 'get_price') @patch.object(SQLDict, '__setitem__') @patch.object(SQLDict, '__contains__') def test_faucet_failure(self, mock_sql_dict_contains, - mock_sql_dict_setitem): - print("\n\nShould not add address to processed list due to faucet error") + mock_sql_dict_setitem, + mock_get_price): + """ + Should not add address to processed list due to faucet error + """ + sol_price = 341 + airdrop_amount = int(pow(10, self.neon_decimals) * (AIRDROP_AMOUNT_SOL * sol_price) / NEON_PRICE_USD) + mock_get_price.side_effect = [sol_price] mock_sql_dict_contains.side_effect = [False] # new eth address - self.faucet.request_eth_token_mock.side_effect = [Response("{}", status=400, mimetype='application/json')] + self.faucet.request_neon_in_galans_mock.side_effect = [Response("{}", status=400, mimetype='application/json')] self.airdropper.process_trx_airdropper_mode(pre_token_airdrop_trx1) mock_sql_dict_contains.assert_called_once_with(token_airdrop_address1) + mock_get_price.assert_called_once_with('SOL/USD') mock_sql_dict_setitem.assert_not_called() - json_req = {'wallet': token_airdrop_address1, 'amount': self.airdrop_amount} - self.faucet.request_eth_token_mock.assert_called_once_with(json_req) - self.faucet.request_eth_token_mock.reset_mock() + json_req = {'wallet': token_airdrop_address1, 'amount': airdrop_amount} + self.faucet.request_neon_in_galans_mock.assert_called_once_with(json_req) + self.faucet.request_neon_in_galans_mock.reset_mock() @patch.object(SQLDict, '__setitem__') @@ -128,37 +171,49 @@ def test_faucet_failure(self, def test_process_trx_with_one_airdrop_for_already_processed_address(self, mock_sql_dict_contains, mock_sql_dict_setitem): - print("\n\nShould not airdrop to repeated address") + """ + Should not airdrop to repeated address + """ mock_sql_dict_contains.side_effect = [True] # eth address processed later self.airdropper.process_trx_airdropper_mode(pre_token_airdrop_trx1) mock_sql_dict_contains.assert_called_once_with(token_airdrop_address1) mock_sql_dict_setitem.assert_not_called() - self.faucet.request_eth_token_mock.assert_not_called() - self.faucet.request_eth_token_mock.reset_mock() + self.faucet.request_neon_in_galans_mock.assert_not_called() + self.faucet.request_neon_in_galans_mock.reset_mock() + @patch.object(PriceProvider, 'get_price') @patch.object(SQLDict, '__setitem__') @patch.object(SQLDict, '__contains__') def test_complex_transation(self, mock_sql_dict_contains, - mock_sql_dict_setitem): - print("\n\nShould airdrop to several targets in one transaction") + mock_sql_dict_setitem, + mock_get_price): + """ + Should airdrop to several targets in one transaction + """ + sol_price1 = 341 + sol_price2 = 225 + airdrop_amount1 = int(pow(10, self.neon_decimals) * (AIRDROP_AMOUNT_SOL * sol_price1) / NEON_PRICE_USD) + airdrop_amount2 = int(pow(10, self.neon_decimals) * (AIRDROP_AMOUNT_SOL * sol_price2) / NEON_PRICE_USD) + mock_get_price.side_effect = [sol_price1, sol_price2] mock_sql_dict_contains.side_effect = [False, False] # both targets are new - self.faucet.request_eth_token_mock.side_effect = [Response("{}", status=200, mimetype='application/json'), - Response("{}", status=200, mimetype='application/json')] + self.faucet.request_neon_in_galans_mock.side_effect = [Response("{}", status=200, mimetype='application/json'), + Response("{}", status=200, mimetype='application/json')] self.airdropper.process_trx_airdropper_mode(pre_token_airdrop_trx2) mock_sql_dict_contains.assert_has_calls([call(token_airdrop_address3), call(token_airdrop_address2)]) + mock_get_price.assert_has_calls([call('SOL/USD')]* 2) mock_sql_dict_setitem.assert_has_calls([call(token_airdrop_address3, ANY), call(token_airdrop_address2, ANY)]) - json_req1 = {'wallet': token_airdrop_address2, 'amount': self.airdrop_amount} - json_req2 = {'wallet': token_airdrop_address3, 'amount': self.airdrop_amount} - self.faucet.request_eth_token_mock.assert_has_calls([call(json_req2), call(json_req1)]) - self.faucet.request_eth_token_mock.reset_mock() + json_req1 = {'wallet': token_airdrop_address2, 'amount': airdrop_amount2} + json_req2 = {'wallet': token_airdrop_address3, 'amount': airdrop_amount1} + self.faucet.request_neon_in_galans_mock.assert_has_calls([call(json_req2), call(json_req1)]) + self.faucet.request_neon_in_galans_mock.reset_mock() @patch.object(SQLDict, '__setitem__') @@ -166,11 +221,13 @@ def test_complex_transation(self, def test_no_airdrop_instructions(self, mock_sql_dict_contains, mock_sql_dict_setitem): - print("\n\nShould not airdrop when instructions are inconsistent") + """ + Should not airdrop when instructions are inconsistent + """ self.airdropper.process_trx_airdropper_mode(create_sol_acc_and_airdrop_trx) mock_sql_dict_contains.assert_not_called() mock_sql_dict_setitem.assert_not_called() - self.faucet.request_eth_token_mock.assert_not_called() - self.faucet.request_eth_token_mock.reset_mock() + self.faucet.request_neon_in_galans_mock.assert_not_called() + self.faucet.request_neon_in_galans_mock.reset_mock() diff --git a/proxy/testing/test_price_provider.py b/proxy/testing/test_price_provider.py new file mode 100644 index 000000000..3ee617d73 --- /dev/null +++ b/proxy/testing/test_price_provider.py @@ -0,0 +1,205 @@ +from proxy.indexer.price_provider import PriceProvider, field_info, PRICE_STATUS_TRADING,\ + PRICE_STATUS_UNKNOWN, \ + testnet_price_accounts, \ + devnet_price_accounts, \ + mainnet_price_accounts, \ + mainnet_solana, \ + testnet_solana, \ + devnet_solana +from unittest import TestCase +from unittest.mock import patch, MagicMock, call +from solana.rpc.api import Client +from solana.publickey import PublicKey +from struct import pack +from random import uniform +import base58, base64 + + +def _create_price_account_info(price: float, status: int, enc: str): + # Follow link https://github.com/pyth-network/pyth-client-rs/blob/main/src/lib.rs + # for details on structure of pyth.network price accounts. + # Current implementation of PriceProvider uses only few fields of account + # so no need to generate all data in tests + + exponent = -8 # use as default + # Fill gap between account data begining and expo field with zeros + data = b'\x00' * field_info['expo']['pos'] + data += pack(field_info['expo']['format'], exponent) + + raw_price = int(price / pow(10, exponent)) + # fill gap between expo and agg.price fields with zeros + data += b'\x00' * (field_info['agg.price']['pos'] - len(data)) + data += pack(field_info['agg.price']['format'], raw_price) + + # fill gap between agg.price and agg.status fields with zeros + data += b'\x00' * (field_info['agg.status']['pos'] - len(data)) + data += pack(field_info['agg.status']['format'], status) + # rest of data array is not used by PriceProvier so no need to fill it + + if enc == 'base58': + data = base58.b58encode(data) + elif enc == 'base64': + data = base64.b64encode(data) + else: + raise Exception(f"Unsupported encoding: {enc}") + + return {'result': {'value': {'data': [data, enc]}}} + + +class TestPriceProvider(TestCase): + @classmethod + def setUpClass(cls) -> None: + print("Testing PriceProvider") + cls.default_upd_int = 10 + + + def setUp(self) -> None: + print(f"\n\n{self._testMethodName}\n{self._testMethodDoc}") + self.testnet_price_provider = PriceProvider(testnet_solana, + self.default_upd_int, + testnet_price_accounts) + + + @patch.object(Client, 'get_account_info') + @patch.object(PriceProvider, '_get_current_time') + def test_success_read_price_two_times_with_small_interval(self, mock_get_current_time, mock_get_account_info): + """ + Testing two sequential calls with small interval. Should read account once + """ + first_call_time = uniform(0, 100000) # some random time + # not enough time left to cause second account reload + second_call_time = first_call_time + self.default_upd_int - 1 + + mock_get_current_time.side_effect = [ first_call_time, second_call_time] + + current_price = 315.0 + mock_get_account_info.side_effect = [_create_price_account_info(current_price, + PRICE_STATUS_TRADING, + 'base58')] + + pair_name = 'SOL/USD' + self.assertEqual(self.testnet_price_provider.get_price(pair_name), current_price) + self.assertEqual(self.testnet_price_provider.get_price(pair_name), current_price) + + mock_get_current_time.assert_has_calls([call(), call()]) + mock_get_account_info.assert_called_once_with(PublicKey(testnet_price_accounts[pair_name])) + + + @patch.object(Client, 'get_account_info') + @patch.object(PriceProvider, '_get_current_time') + def test_success_read_price_two_times_with_long_interval_diff_encodings(self, mock_get_current_time, mock_get_account_info): + """ + Testing two sequential calls with long interval. Should read account twice + """ + first_call_time = uniform(0, 100000) # some random time + # Time interval between 1st and 2nd calls are larger that reload interval + second_call_time = first_call_time + self.default_upd_int + 2 + + mock_get_current_time.side_effect = [ first_call_time, second_call_time] + + current_price = 315.0 + mock_get_account_info.side_effect = [_create_price_account_info(current_price, + PRICE_STATUS_TRADING, + 'base58'), + _create_price_account_info(current_price, + PRICE_STATUS_TRADING, + 'base64')] + + pair_name = 'SOL/USD' + self.assertEqual(self.testnet_price_provider.get_price(pair_name), current_price) + self.assertEqual(self.testnet_price_provider.get_price(pair_name), current_price) + + price_acc_key = PublicKey(testnet_price_accounts[pair_name]) + mock_get_current_time.assert_has_calls([call(), call()]) + mock_get_account_info.assert_has_calls([call(price_acc_key), call(price_acc_key)]) + + @patch.object(Client, 'get_account_info') + @patch.object(PriceProvider, '_get_current_time') + def test_faile_get_price_price_status_not_trading(self, mock_get_current_time, mock_get_account_info): + """ + get_price call should return None because last price account data is not trading + """ + first_call_time = uniform(0, 100000) # some random time + + mock_get_current_time.side_effect = [first_call_time] + + current_price = 315.0 + mock_get_account_info.side_effect = [_create_price_account_info(current_price, + PRICE_STATUS_UNKNOWN, + 'base58')] + + pair_name = 'SOL/USD' + self.assertEqual(self.testnet_price_provider.get_price(pair_name), None) + + price_acc_key = PublicKey(testnet_price_accounts[pair_name]) + mock_get_current_time.assert_has_calls([call()]) + mock_get_account_info.assert_has_calls([call(price_acc_key)]) + + + @patch.object(Client, 'get_account_info') + @patch.object(PriceProvider, '_get_current_time') + def test_failed_read_account_not_found(self, mock_get_current_time, mock_get_account_info): + """ + Account reading will fail due to unknown pair provided + """ + first_call_time = uniform(0, 100000) # some random time + mock_get_current_time.side_effect = [ first_call_time ] + + pair_name = 'RUB/USD' # Unknown pair + self.assertEqual(self.testnet_price_provider.get_price(pair_name), None) + + mock_get_current_time.assert_has_calls([call()]) + mock_get_account_info.assert_not_called() + + + @patch.object(Client, 'get_account_info') + @patch.object(PriceProvider, '_get_current_time') + def test_failed_second_acc_read_will_return_previous_result(self, mock_get_current_time, mock_get_account_info): + """ + Testing two sequential calls with long interval. Second call will fail. Provider should return previous price + """ + first_call_time = uniform(0, 100000) # some random time + # Time interval between 1st and 2nd calls are larger that reload interval + second_call_time = first_call_time + self.default_upd_int + 2 + + mock_get_current_time.side_effect = [ first_call_time, second_call_time] + + current_price = 315.0 + mock_get_account_info.side_effect = [_create_price_account_info(current_price, + PRICE_STATUS_TRADING, + 'base58'), + {'result':{}}] # << Wrong message format + + pair_name = 'SOL/USD' + self.assertEqual(self.testnet_price_provider.get_price(pair_name), current_price) + self.assertEqual(self.testnet_price_provider.get_price(pair_name), current_price) + + price_acc_key = PublicKey(testnet_price_accounts[pair_name]) + mock_get_current_time.assert_has_calls([call(), call()]) + mock_get_account_info.assert_has_calls([call(price_acc_key), call(price_acc_key)]) + + + def test_compare_mainnet_testnet_data(self): + """ + Should return correct prices on all Solana nets + """ + pair_name = 'SOL/USD' + + devnet_price_provider = PriceProvider(devnet_solana, + self.default_upd_int, + devnet_price_accounts) + mainnet_price_provider = PriceProvider(mainnet_solana, + self.default_upd_int, + mainnet_price_accounts) + + devnet_price = devnet_price_provider.get_price(pair_name) + testnet_price = self.testnet_price_provider.get_price(pair_name) + mainnet_price = mainnet_price_provider.get_price(pair_name) + + print(f"Solana devnet: SOL/USD = {devnet_price}") + print(f"Solana testnet: SOL/USD = {testnet_price}") + print(f"Solana mainnet: SOL/USD = {mainnet_price}") + + self.assertTrue(devnet_price is not None) + self.assertTrue(testnet_price is not None) + self.assertTrue(mainnet_price is not None)