From a86a8c0e326229e946d82c1fde1987d3e925c68b Mon Sep 17 00:00:00 2001 From: Isaac Adewumi Date: Fri, 11 Oct 2024 17:31:29 +0100 Subject: [PATCH] add support for other emv chains and remove circle api support --- backend/Makefile | 14 +- backend/bridgebloc/apps/conversions/apps.py | 6 +- .../bridgebloc/apps/conversions/constants.py | 42 ---- backend/bridgebloc/apps/conversions/enums.py | 6 - backend/bridgebloc/apps/conversions/models.py | 16 +- .../apps/conversions/serializers.py | 79 +------ backend/bridgebloc/apps/conversions/tasks.py | 194 +----------------- backend/bridgebloc/apps/conversions/types.py | 1 - backend/bridgebloc/apps/conversions/urls.py | 12 -- backend/bridgebloc/apps/conversions/utils.py | 49 +---- backend/bridgebloc/apps/conversions/views.py | 89 +------- .../management/commands/fetch_tokens.py | 153 +++++++------- backend/bridgebloc/apps/tokens/models.py | 1 + backend/bridgebloc/conf/settings.py | 14 +- backend/bridgebloc/evm/aggregator.py | 4 + backend/bridgebloc/evm/types.py | 30 +-- backend/bridgebloc/services/circle.py | 102 --------- 17 files changed, 128 insertions(+), 684 deletions(-) delete mode 100644 backend/bridgebloc/apps/conversions/constants.py delete mode 100644 backend/bridgebloc/services/circle.py diff --git a/backend/Makefile b/backend/Makefile index 7f44d6a..79c8e07 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -3,15 +3,15 @@ LINT_PATHS = bridgebloc/ manage.py include .env.dev lint: - isort $(LINT_PATHS) --diff --check-only - ruff $(LINT_PATHS) - pylint $(LINT_PATHS) - mypy $(LINT_PATHS) --install-types --non-interactive + uv run isort $(LINT_PATHS) --diff --check-only + uv run ruff $(LINT_PATHS) + uv run pylint $(LINT_PATHS) + uv run mypy $(LINT_PATHS) --install-types --non-interactive format: - isort $(LINT_PATHS) - ruff $(LINT_PATHS) --fix - black $(LINT_PATHS) + uv run isort $(LINT_PATHS) + uv run ruff $(LINT_PATHS) --fix + uv run black $(LINT_PATHS) test: @echo "Running tests..." diff --git a/backend/bridgebloc/apps/conversions/apps.py b/backend/bridgebloc/apps/conversions/apps.py index 579651b..ef56db8 100644 --- a/backend/bridgebloc/apps/conversions/apps.py +++ b/backend/bridgebloc/apps/conversions/apps.py @@ -11,15 +11,17 @@ class ConversionsConfig(AppConfig): def ready(self) -> None: EVMAggregator.initialize( config=EVMAggregatorConfig( + base_endpoints=settings.BASE_RPC_NODES, ethereum_endpoints=settings.ETHEREUM_RPC_NODES, + optimism_endpoints=settings.OPTIMISM_RPC_NODES, avalanche_endpoints=settings.AVALANCHE_RPC_NODES, polygon_pos_endpoints=settings.POLYGON_POS_RPC_NODES, arbitrum_one_endpoints=settings.ARBITRUM_ONE_RPC_NODES, - polygon_zkevm_endpoints=settings.POLYGON_ZKEVM_RPC_NODES, + base_testnet_endpoints=settings.BASE_TESTNET_RPC_NODES, + optimism_testnet_endpoints=settings.OPTIMISM_TESTNET_RPC_NODES, ethereum_testnet_endpoints=settings.ETHEREUM_TESTNET_RPC_NODES, avalanche_testnet_endpoints=settings.AVALANCHE_TESTNET_RPC_NODES, polygon_pos_testnet_endpoints=settings.POLYGON_POS_TESTNET_RPC_NODES, arbitrum_one_testnet_endpoints=settings.ARBITRUM_ONE_TESTNET_RPC_NODES, - polygon_zkevm_testnet_endpoints=settings.POLYGON_ZKEVM_TESTNET_RPC_NODES, ), ) diff --git a/backend/bridgebloc/apps/conversions/constants.py b/backend/bridgebloc/apps/conversions/constants.py deleted file mode 100644 index 75b6659..0000000 --- a/backend/bridgebloc/apps/conversions/constants.py +++ /dev/null @@ -1,42 +0,0 @@ -from bridgebloc.evm.types import ChainID - -from .types import ConversionMethod - -VALID_CONVERSION_ROUTES = { - ChainID.ETHEREUM: { - ChainID.ARBITRUM_ONE: ConversionMethod.CCTP, - ChainID.AVALANCHE: ConversionMethod.CCTP, - ChainID.POLYGON_POS: ConversionMethod.CIRCLE_API, - }, - ChainID.ETHEREUM_TESTNET: { - ChainID.ARBITRUM_ONE_TESTNET: ConversionMethod.CCTP, - ChainID.AVALANCHE_TESTNET: ConversionMethod.CCTP, - ChainID.POLYGON_POS_TESTNET: ConversionMethod.CIRCLE_API - }, - ChainID.POLYGON_POS: { - ChainID.ETHEREUM: ConversionMethod.CIRCLE_API, - ChainID.AVALANCHE: ConversionMethod.CIRCLE_API, - }, - ChainID.POLYGON_POS_TESTNET: { - ChainID.ETHEREUM_TESTNET: ConversionMethod.CIRCLE_API, - ChainID.AVALANCHE_TESTNET: ConversionMethod.CIRCLE_API, - }, - ChainID.AVALANCHE: { - ChainID.ETHEREUM: ConversionMethod.CCTP, - ChainID.ARBITRUM_ONE: ConversionMethod.CCTP, - ChainID.POLYGON_POS: ConversionMethod.CIRCLE_API, - }, - ChainID.AVALANCHE_TESTNET: { - ChainID.ETHEREUM_TESTNET: ConversionMethod.CCTP, - ChainID.ARBITRUM_ONE_TESTNET: ConversionMethod.CCTP, - ChainID.POLYGON_POS_TESTNET: ConversionMethod.CIRCLE_API, - }, - ChainID.ARBITRUM_ONE: { - ChainID.ETHEREUM: ConversionMethod.CCTP, - ChainID.AVALANCHE: ConversionMethod.CCTP, - }, - ChainID.ARBITRUM_ONE_TESTNET: { - ChainID.ETHEREUM_TESTNET: ConversionMethod.CCTP, - ChainID.AVALANCHE_TESTNET: ConversionMethod.CCTP, - }, -} diff --git a/backend/bridgebloc/apps/conversions/enums.py b/backend/bridgebloc/apps/conversions/enums.py index 83ef8f0..6aec379 100644 --- a/backend/bridgebloc/apps/conversions/enums.py +++ b/backend/bridgebloc/apps/conversions/enums.py @@ -7,12 +7,6 @@ class TokenConversionStepStatus(models.TextChoices): SUCCESSFUL = 'successful' -class CircleAPIConversionStepType(models.TextChoices): - CONFIRM_DEPOSIT = 'confirm deposit' - SEND_TO_RECIPIENT = 'send to recipient' - CREATE_DEPOSIT_ADDRESS = 'create deposit address' - - class CCTPConversionStepType(models.TextChoices): ATTESTATION_SERVICE_CONFIRMATION = 'attestation service confirmation' SEND_TO_RECIPIENT = 'send to recipient' diff --git a/backend/bridgebloc/apps/conversions/models.py b/backend/bridgebloc/apps/conversions/models.py index 77c264b..c43f494 100644 --- a/backend/bridgebloc/apps/conversions/models.py +++ b/backend/bridgebloc/apps/conversions/models.py @@ -5,7 +5,7 @@ from bridgebloc.common.fields import EVMAddressField, EVMChainIDField from bridgebloc.common.models import TimestampedModel, UUIDModel -from .enums import CCTPConversionStepType, CircleAPIConversionStepType, TokenConversionStepStatus +from .enums import CCTPConversionStepType, TokenConversionStepStatus from .types import ConversionMethod @@ -22,7 +22,6 @@ class TokenConversion(UUIDModel, TimestampedModel, models.Model): source_token = models.ForeignKey( 'tokens.Token', verbose_name='source token', - related_name='source_circle_api_conversions', on_delete=models.CASCADE, blank=False, ) @@ -35,7 +34,6 @@ class TokenConversion(UUIDModel, TimestampedModel, models.Model): destination_token = models.ForeignKey( 'tokens.Token', verbose_name='destination token', - related_name='destination_circle_api_conversions', on_delete=models.CASCADE, blank=False, ) @@ -43,21 +41,13 @@ class TokenConversion(UUIDModel, TimestampedModel, models.Model): amount = models.DecimalField('amount', max_digits=14, decimal_places=2, blank=False) """ Represents the value to be transferred or exchanged based on the bridging method: - - 1. For tokens bridged via Circle API: - This indicates the quantity of USDC to be transferred. - - 2. For tokens bridged via CCTP: + 1. For tokens bridged via CCTP: This represents the equivalent value of the source_token in USDC. """ @property def actual_amount(self) -> Decimal: max_fee = Decimal(20) - if self.conversion_type == ConversionMethod.CIRCLE_API: - fee_charged = min(Decimal(0.04) * self.amount, max_fee) - return self.amount - fee_charged - fee_charged = min(Decimal(0.03) * self.amount, max_fee) return self.amount - fee_charged @@ -73,7 +63,7 @@ class TokenConversionStep(UUIDModel, TimestampedModel, models.Model): step_type = models.CharField( 'step type', max_length=150, - choices=CircleAPIConversionStepType.choices + CCTPConversionStepType.choices, + choices=CCTPConversionStepType.choices, blank=False, ) metadata = models.JSONField('metadata', blank=False) diff --git a/backend/bridgebloc/apps/conversions/serializers.py b/backend/bridgebloc/apps/conversions/serializers.py index 980a6fe..077a4f9 100644 --- a/backend/bridgebloc/apps/conversions/serializers.py +++ b/backend/bridgebloc/apps/conversions/serializers.py @@ -1,4 +1,3 @@ -import re from typing import Any from eth_utils.address import to_checksum_address @@ -16,23 +15,7 @@ from bridgebloc.evm.types import ChainID from .models import TokenConversion, TokenConversionStep -from .types import ConversionMethod -from .utils import ( - get_cross_chain_bridge_deployment_address, - get_token_messenger_deployment_address, - is_valid_route, -) - - -class CircleTokenConversionDepositTxHashUpdateSerializer(serializers.Serializer): - tx_hash = serializers.CharField(required=True) - - def validate_tx_hash(self, value: str) -> str: - is_valid_hash = re.fullmatch('^0x[a-fA-F0-9]{64}', value) - if not bool(is_valid_hash): - raise serializers.ValidationError('Invalid transaction hash provided') - - return value +from .utils import get_cross_chain_bridge_deployment_address, get_token_messenger_deployment_address class TokenConversionStepSerializer(serializers.ModelSerializer): @@ -65,63 +48,6 @@ class Meta: ) -class CircleAPITokenConversionInitialisationSerializer(serializers.Serializer): - source_chain = serializers.CharField(required=True) - source_token = serializers.CharField(required=True) - destination_chain = serializers.CharField(required=True) - destination_token = serializers.CharField(required=True) - destination_address = serializers.CharField(required=True) - amount = serializers.DecimalField(required=True, max_digits=16, decimal_places=2, min_value=1) - - def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: - try: - source_chain = ChainID.from_name(attrs['source_chain']) - destination_chain = ChainID.from_name(attrs['destination_chain']) - except ValueError as e: - raise serializers.ValidationError(str(e)) from e - - # Only allow testnet for now since Circle Live API requires verification. - if source_chain.is_mainnet() or destination_chain.is_mainnet(): - raise serializers.ValidationError('Only testnet network is supported via Circle API for now.') - - if source_chain == destination_chain: - raise serializers.ValidationError('source_chain cannot be the same as destination_chain') - - if source_chain.is_mainnet() != destination_chain.is_mainnet(): - raise serializers.ValidationError( - 'Both source_chain and destination_chain must be on the same network (testnet or mainnet)', - ) - - if not is_valid_route(source_chain, destination_chain, ConversionMethod.CIRCLE_API): - raise serializers.ValidationError('Circle API not supported for the source and destination chain') - - try: - source_token = Token.objects.get(address=to_checksum_address(attrs['source_token']), chain_id=source_chain) - if source_token.symbol != 'usdc': - raise serializers.ValidationError('Only USDC bridging is allowed via Circle API') - except Token.DoesNotExist as e: - raise serializers.ValidationError('Token does not exist') from e - - try: - destination_token = Token.objects.get( - chain_id=destination_chain, - address=to_checksum_address(attrs['destination_token']), - ) - if destination_token.symbol != 'usdc': - raise serializers.ValidationError('Only USDC bridging is allowed via Circle API') - except Token.DoesNotExist as e: - raise serializers.ValidationError('Token does not exist') from e - - return { - 'amount': attrs['amount'], - 'source_chain': source_chain, - 'source_token': source_token, - 'destination_chain': destination_chain, - 'destination_token': destination_token, - 'destination_address': to_checksum_address(attrs['destination_address']), - } - - class CCTPTokenConversionInitialisationSerializer(serializers.Serializer): tx_hash = serializers.CharField(required=True) source_chain = serializers.CharField(required=True) @@ -142,9 +68,6 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: 'Both source_chain and destination_chain must be on the same network (testnet or mainnet)', ) - if not is_valid_route(source_chain, destination_chain, ConversionMethod.CCTP): - raise serializers.ValidationError('CCTP not supported for the source and destination chain') - evm_client = EVMAggregator().get_client(source_chain) # pylint:disable=no-value-for-parameter tx_receipt = evm_client.get_transaction_receipt(attrs['tx_hash']) info = self._validate_tx_receipt( diff --git a/backend/bridgebloc/apps/conversions/tasks.py b/backend/bridgebloc/apps/conversions/tasks.py index 25e353c..19e108a 100644 --- a/backend/bridgebloc/apps/conversions/tasks.py +++ b/backend/bridgebloc/apps/conversions/tasks.py @@ -1,6 +1,4 @@ import logging -from datetime import datetime -from decimal import Decimal from eth_account import Account from eth_utils.conversions import to_bytes @@ -10,206 +8,18 @@ from django.conf import settings from django.db import transaction -from django.utils import timezone from bridgebloc.apps.tokens.models import Token from bridgebloc.evm.aggregator import EVMAggregator -from .enums import ( - CCTPConversionStepType, - CircleAPIConversionStepType, - TokenConversionStepStatus, -) +from .enums import CCTPConversionStepType, TokenConversionStepStatus from .models import TokenConversionStep from .types import ConversionMethod -from .utils import ( - get_attestation_client, - get_circle_api_client, - get_cross_chain_bridge_deployment_address, -) +from .utils import get_attestation_client, get_cross_chain_bridge_deployment_address logger = logging.getLogger(__name__) -@db_periodic_task(crontab(minute='*/1')) -@lock_task('poll-circle-for-deposit-addresses-lock') -def poll_circle_for_deposit_addresses() -> None: - logger.info('Starting Circle API deposit addresses polling...') - - steps_needing_deposit_addresses = TokenConversionStep.objects.select_related('conversion').filter( - status=TokenConversionStepStatus.PENDING, - conversion__conversion_type=ConversionMethod.CIRCLE_API, - step_type=CircleAPIConversionStepType.CREATE_DEPOSIT_ADDRESS, - ) - - logger.info(f'Found {steps_needing_deposit_addresses.count()} steps requiring deposit addresses.') - - for step in steps_needing_deposit_addresses: - logger.info(f'Polling for deposit address for Token Conversion: {step.conversion.uuid}') - - try: - with transaction.atomic(): - circle_client = get_circle_api_client(step.conversion.source_chain) - response = circle_client.get_payment_intent(step.metadata['id']) - if response['data']['paymentMethods'][0].get('address') is None: - logger.info(f'No deposit address found for Token Conversion: {step.conversion.uuid}. Skipping...') - continue - - step.status = TokenConversionStepStatus.SUCCESSFUL - step.metadata = response['data'] - step.save() - - TokenConversionStep.objects.create( - metadata={'deposit_tx_hash': None, **response['data']}, - conversion=step.conversion, - status=TokenConversionStepStatus.PENDING, - step_type=CircleAPIConversionStepType.CONFIRM_DEPOSIT, - ) - logger.info(f'Received deposit address for Token Conversion: {step.conversion.uuid}') - except Exception: - logger.exception('Error occurred while polling Circle API for deposit address') - continue - - logger.info('Circle API deposit address polling completed.') - - -@db_periodic_task(crontab(minute='*/2')) -@lock_task('check-for-circle-api-deposit-confirmation') -def check_for_circle_api_deposit_confirmation() -> None: - logger.info('Starting Circle API deposit confirmation checks...') - - steps_needing_deposit_addresses = TokenConversionStep.objects.select_related('conversion').filter( - status=TokenConversionStepStatus.PENDING, - step_type=CircleAPIConversionStepType.CONFIRM_DEPOSIT, - conversion__conversion_type=ConversionMethod.CIRCLE_API, - ) - - logger.info(f'Found {steps_needing_deposit_addresses.count()} steps requiring deposit confirmation.') - - for step in steps_needing_deposit_addresses: - logger.info(f'Checking deposit confirmation for step {step.uuid}...') - - try: - with transaction.atomic(): - circle_client = get_circle_api_client(step.conversion.source_chain) - response = circle_client.get_payment_intent(step.metadata['id']) - - if timezone.now() > datetime.fromisoformat(response['data']['expiresOn']): - step.metadata = {'deposit_tx_hash': step.metadata['deposit_tx_hash'], **response['data']} - step.status = TokenConversionStepStatus.FAILED - step.save() - - logger.warning(f'Step {step.uuid} failed due to expiration of payment intent.') - continue - - if ( - response['data']['timeline'][0]['status'] != 'complete' - or response['data']['timeline'][0]['context'] not in ('paid', 'overpaid') - or len(response['data']['paymentIds']) < 1 - or Decimal(response['data']['amountPaid']['amount']) < step.conversion.amount - ): - logger.info(f'Deposit confirmation for step {step.uuid} is not complete or accurate. Skipping...') - continue - - step.status = TokenConversionStepStatus.SUCCESSFUL - step.metadata = {'deposit_tx_hash': step.metadata['deposit_tx_hash'], **response['data']} - step.save() - - logger.info(f'Deposit confirmation for step {step.uuid} succeeded. Proceeding to next step...') - - TokenConversionStep.objects.create( - metadata={}, - conversion=step.conversion, - status=TokenConversionStepStatus.PENDING, - step_type=CircleAPIConversionStepType.SEND_TO_RECIPIENT, - ) - except Exception: - logger.exception('Error occurred while checking for Circle API deposit confirmation') - continue - - logger.info('Circle API deposit confirmation check completed.') - - -@db_periodic_task(crontab(minute='*/3')) -@lock_task('send-to-recipient-using-circle-api-lock') -def send_to_recipient_using_circle_api() -> None: - logger.info('Starting Circle API withdrawal process for recipients...') - - steps_needing_withdrawal = TokenConversionStep.objects.select_related('conversion').filter( - metadata__id__isnull=True, - status=TokenConversionStepStatus.PENDING, - step_type=CircleAPIConversionStepType.SEND_TO_RECIPIENT, - conversion__conversion_type=ConversionMethod.CIRCLE_API, - ) - - logger.info(f'Found {steps_needing_withdrawal.count()} steps requiring withdrawal.') - - for step in steps_needing_withdrawal: - logger.info(f'Processing step {step.uuid} for withdrawal to recipient...') - - try: - with transaction.atomic(): - circle_client = get_circle_api_client(step.conversion.destination_chain) - response = circle_client.make_withdrawal( - amount=step.conversion.actual_amount, - master_wallet_id=settings.CIRCLE_MASTER_WALLET_ID, - chain=step.conversion.destination_chain.to_circle(), - destination_address=step.conversion.destination_address, - ) - step.metadata = response['data'] - step.save() - - logger.info(f'Withdrawal initiated for step {step.uuid}.') - except Exception: - logger.exception('Error occurred while checking for Circle API deposit confirmation') - continue - - logger.info('Circle API withdrawal process completed successfully.') - - -@db_periodic_task(crontab(minute='*/2')) -@lock_task('wait-for-minimum-confirmation-for-circle-api-withdrawals-lock') -def wait_for_minimum_confirmation_for_circle_api_withdrawals() -> None: - logger.info('Starting withdrawal confirmation check for Circle API withdrawals...') - - steps_needing_withdrawal = TokenConversionStep.objects.select_related('conversion').filter( - metadata__id__isnull=False, - status=TokenConversionStepStatus.PENDING, - conversion__conversion_type=ConversionMethod.CIRCLE_API, - step_type=CircleAPIConversionStepType.SEND_TO_RECIPIENT, - ) - - logger.info(f'Found {steps_needing_withdrawal.count()} steps needing withdrawal confirmation.') - - for step in steps_needing_withdrawal: - logger.info(f'Checking withdrawal confirmation for step {step.uuid}...') - - try: - with transaction.atomic(): - circle_client = get_circle_api_client(step.conversion.destination_chain) - response = circle_client.get_withdrawal_info(step.metadata['id']) - if ( - response['data']['status'] in {'running', 'complete'} - and response['data']['transactionHash'] is not None - ): - step.metadata = response['data'] - step.status = TokenConversionStepStatus.SUCCESSFUL - step.save() - - logger.info(f'Withdrawal for step {step.uuid} confirmed and marked as successful.') - - if response['data']['status'] == 'failed': - error_code = response['data']['errorCode'] - step.metadata = response['data'] - step.save() - logger.warning(f'Withdrawal for step {step.uuid} failed with error code {error_code}') - except Exception: - logger.exception('Error occurred while checking for Circle API deposit confirmation') - continue - - logger.info('Withdrawals confirmation check completed.') - - @db_periodic_task(crontab(minute='*/2')) @lock_task('check-for-cctp-attestation-confirmation-lock') def check_for_cctp_attestation_confirmation() -> None: diff --git a/backend/bridgebloc/apps/conversions/types.py b/backend/bridgebloc/apps/conversions/types.py index e5b7086..057075c 100644 --- a/backend/bridgebloc/apps/conversions/types.py +++ b/backend/bridgebloc/apps/conversions/types.py @@ -4,7 +4,6 @@ @unique class ConversionMethod(StrEnum): CCTP = 'cctp' - CIRCLE_API = 'circle_api' def __str__(self) -> str: return str(self.value) diff --git a/backend/bridgebloc/apps/conversions/urls.py b/backend/bridgebloc/apps/conversions/urls.py index e68b26a..7a0837b 100644 --- a/backend/bridgebloc/apps/conversions/urls.py +++ b/backend/bridgebloc/apps/conversions/urls.py @@ -2,8 +2,6 @@ from .views import ( CCTPTokenConversionInitialisationAPIView, - CircleAPITokenConversionInitialisationAPIView, - CircleTokenConversionDepositTxHashUpdateAPIView, TokenConversionAPIView, TokenConversionsAPIView, ValidTokenConversionRoutesAPIView, @@ -11,16 +9,6 @@ urlpatterns = [ path('conversions', TokenConversionsAPIView.as_view(), name='all-conversions-by-user'), - path( - 'conversions/circle-api', - CircleAPITokenConversionInitialisationAPIView.as_view(), - name='bridge-with-circle-api', - ), - path( - 'conversions/circle-api//add-deposit-hash', - CircleTokenConversionDepositTxHashUpdateAPIView.as_view(), - name='add-deposit-tx-hash', - ), path('conversions/cctp', CCTPTokenConversionInitialisationAPIView.as_view(), name='bridge-with-cctp'), path('conversions/routes', ValidTokenConversionRoutesAPIView.as_view(), name='valid-routes'), path('conversions/', TokenConversionAPIView.as_view(), name='get-conversion'), diff --git a/backend/bridgebloc/apps/conversions/utils.py b/backend/bridgebloc/apps/conversions/utils.py index d5e0b9c..fde6180 100644 --- a/backend/bridgebloc/apps/conversions/utils.py +++ b/backend/bridgebloc/apps/conversions/utils.py @@ -5,61 +5,28 @@ from bridgebloc.evm.types import ChainID from bridgebloc.services.attestation import AttestationService -from bridgebloc.services.circle import CircleAPI - -from .constants import VALID_CONVERSION_ROUTES -from .types import ConversionMethod - - -def is_valid_route(source: ChainID, dest: ChainID, method: ConversionMethod) -> bool: - return VALID_CONVERSION_ROUTES[source].get(dest) == method - - -def get_circle_api_client(chain: ChainID) -> CircleAPI: - if chain.is_mainnet(): - return CircleAPI(api_key=settings.CIRCLE_LIVE_API_KEY, base_url=settings.CIRCLE_LIVE_BASE_URL) - - return CircleAPI(api_key=settings.CIRCLE_SANDBOX_API_KEY, base_url=settings.CIRCLE_SANDBOX_BASE_URL) def get_cross_chain_bridge_deployment_address(chain: ChainID) -> ChecksumAddress: if not chain.is_valid_cctp_chain(): raise ValueError(f'{chain} is not a valid CCTP chain') - if chain == ChainID.ETHEREUM: - return to_checksum_address(settings.CROSS_CHAIN_BRIDGE_ETHEREUM_DEPLOYED_ADDRESS) - if chain == ChainID.ETHEREUM_TESTNET: - return to_checksum_address(settings.CROSS_CHAIN_BRIDGE_ETHEREUM_TESTNET_DEPLOYED_ADDRESS) - if chain == ChainID.AVALANCHE: - return to_checksum_address(settings.CROSS_CHAIN_BRIDGE_AVALANCHE_DEPLOYED_ADDRESS) - if chain == ChainID.AVALANCHE_TESTNET: - return to_checksum_address(settings.CROSS_CHAIN_BRIDGE_AVALANCHE_TESTNET_DEPLOYED_ADDRESS) - if chain == ChainID.ARBITRUM_ONE: - return to_checksum_address(settings.CROSS_CHAIN_BRIDGE_ARBITRUM_ONE_DEPLOYED_ADDRESS) - if chain == ChainID.ARBITRUM_ONE_TESTNET: - return to_checksum_address(settings.CROSS_CHAIN_BRIDGE_ARBITRUM_ONE_TESTNET_DEPLOYED_ADDRESS) + address = getattr(settings, f'CROSS_CHAIN_BRIDGE_{chain.name}_DEPLOYED_ADDRESS', None) + if not address: + raise ValueError(f'CrossChainBridge not deployed on chain {chain}') - raise ValueError(f'CrossChainBridge not deployed on chain {chain}') + return to_checksum_address(address) def get_token_messenger_deployment_address(chain: ChainID) -> ChecksumAddress: if not chain.is_valid_cctp_chain(): raise ValueError(f'{chain} is not a valid CCTP chain') - if chain == ChainID.ETHEREUM: - return to_checksum_address(settings.TOKEN_MESSENGER_ETHEREUM_DEPLOYED_ADDRESS) - if chain == ChainID.ETHEREUM_TESTNET: - return to_checksum_address(settings.TOKEN_MESSENGER_ETHEREUM_TESTNET_DEPLOYED_ADDRESS) - if chain == ChainID.AVALANCHE: - return to_checksum_address(settings.TOKEN_MESSENGER_AVALANCHE_DEPLOYED_ADDRESS) - if chain == ChainID.AVALANCHE_TESTNET: - return to_checksum_address(settings.TOKEN_MESSENGER_AVALANCHE_TESTNET_DEPLOYED_ADDRESS) - if chain == ChainID.ARBITRUM_ONE: - return to_checksum_address(settings.TOKEN_MESSENGER_ARBITRUM_ONE_DEPLOYED_ADDRESS) - if chain == ChainID.ARBITRUM_ONE_TESTNET: - return to_checksum_address(settings.TOKEN_MESSENGER_ARBITRUM_ONE_TESTNET_DEPLOYED_ADDRESS) + address = getattr(settings, f'TOKEN_MESSENGER_{chain.name}_DEPLOYED_ADDRESS', None) + if not address: + raise ValueError(f'TokenMessenger not deployed on chain {chain}') - raise ValueError(f'TokenMessenger not deployed on chain {chain}') + return to_checksum_address(address) def get_attestation_client(chain: ChainID) -> AttestationService: diff --git a/backend/bridgebloc/apps/conversions/views.py b/backend/bridgebloc/apps/conversions/views.py index bb85c8d..7cf82b5 100644 --- a/backend/bridgebloc/apps/conversions/views.py +++ b/backend/bridgebloc/apps/conversions/views.py @@ -1,4 +1,3 @@ -from collections import defaultdict from typing import Any from django.db import transaction @@ -8,62 +7,16 @@ from rest_framework.generics import GenericAPIView, ListAPIView, RetrieveAPIView from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.views import APIView from bridgebloc.apps.accounts.permissions import IsAuthenticated -from bridgebloc.common.helpers import error_response, success_response +from bridgebloc.common.helpers import success_response from bridgebloc.common.types import AuthenticatedRequest -from .constants import VALID_CONVERSION_ROUTES -from .enums import ( - CCTPConversionStepType, - CircleAPIConversionStepType, - TokenConversionStepStatus, -) +from .enums import CCTPConversionStepType, TokenConversionStepStatus from .models import TokenConversion, TokenConversionStep from .permissions import IsOwner -from .serializers import ( - CCTPTokenConversionInitialisationSerializer, - CircleAPITokenConversionInitialisationSerializer, - CircleTokenConversionDepositTxHashUpdateSerializer, - TokenConversionSerializer, -) +from .serializers import CCTPTokenConversionInitialisationSerializer, TokenConversionSerializer from .types import ConversionMethod -from .utils import get_circle_api_client - - -class ValidTokenConversionRoutesAPIView(APIView): - def get(self, request: Request, *args: Any, **kwargs: Any) -> Response: # noqa: ARG002 - data: dict[str, Any] = defaultdict(lambda: defaultdict()) # pylint:disable=unnecessary-lambda - for key, val in VALID_CONVERSION_ROUTES.items(): - for k, v in val.items(): - data[key.name.lower()][k.name.lower()] = v - - return success_response(data=data) - - -class CircleTokenConversionDepositTxHashUpdateAPIView(GenericAPIView): - queryset = TokenConversion.objects.select_related('creator').prefetch_related('conversion_steps') - serializer_class = CircleTokenConversionDepositTxHashUpdateSerializer - permission_classes = (IsAuthenticated, IsOwner) - lookup_field = 'uuid' - - def patch(self, request: Request, *args: Any, **kwargs: Any) -> Response: # noqa: ARG002 - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - obj = self.get_object() - step = obj.conversion_steps.filter(step_type=CircleAPIConversionStepType.CONFIRM_DEPOSIT).first() - if step is None: - return error_response( - errors=None, - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - message='Token conversion is not of Circle API type or deposit address has not been created', - ) - - step.metadata['deposit_tx_hash'] = serializer.validated_data['tx_hash'] - step.save() - return success_response(data=None) class TokenConversionAPIView(RetrieveAPIView): @@ -91,38 +44,6 @@ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: # noqa return success_response(data=response.data, status_code=response.status_code) -class CircleAPITokenConversionInitialisationAPIView(GenericAPIView): - permission_classes = (IsAuthenticated,) - serializer_class = CircleAPITokenConversionInitialisationSerializer - - def post(self, request: AuthenticatedRequest, *args: Any, **kwargs: Any) -> Response: # noqa: ARG002 - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - with transaction.atomic(): - conversion = TokenConversion.objects.create( - creator=request.user, - amount=serializer.validated_data['amount'], - conversion_type=ConversionMethod.CIRCLE_API, - source_chain=serializer.validated_data['source_chain'], - source_token=serializer.validated_data['source_token'], - destination_address=serializer.validated_data['destination_address'], - destination_chain=serializer.validated_data['destination_chain'], - destination_token=serializer.validated_data['destination_token'], - ) - circle_client = get_circle_api_client(conversion.source_chain) - result = circle_client.create_payment_intent( - amount=conversion.amount, - chain=conversion.source_chain.to_circle(), - ) - TokenConversionStep.objects.create( - metadata=result['data'], - conversion=conversion, - status=TokenConversionStepStatus.PENDING, - step_type=CircleAPIConversionStepType.CREATE_DEPOSIT_ADDRESS, - ) - return success_response(data={'id': conversion.uuid}, status_code=status.HTTP_201_CREATED) - - class CCTPTokenConversionInitialisationAPIView(GenericAPIView): permission_classes = (IsAuthenticated,) serializer_class = CCTPTokenConversionInitialisationSerializer @@ -136,11 +57,11 @@ def post(self, request: AuthenticatedRequest, *args: Any, **kwargs: Any) -> Resp creator=request.user, amount=serializer.validated_data['amount'], conversion_type=ConversionMethod.CCTP, - source_chain=serializer.validated_data['source_chain'], source_token=serializer.validated_data['source_token'], - destination_address=serializer.validated_data['destination_address'], + source_chain=serializer.validated_data['source_chain'], destination_chain=serializer.validated_data['destination_chain'], destination_token=serializer.validated_data['destination_token'], + destination_address=serializer.validated_data['destination_address'], ) TokenConversionStep.objects.create( conversion=conversion, diff --git a/backend/bridgebloc/apps/tokens/management/commands/fetch_tokens.py b/backend/bridgebloc/apps/tokens/management/commands/fetch_tokens.py index f9bf351..b683587 100644 --- a/backend/bridgebloc/apps/tokens/management/commands/fetch_tokens.py +++ b/backend/bridgebloc/apps/tokens/management/commands/fetch_tokens.py @@ -1,112 +1,101 @@ -from pathlib import Path +import logging from typing import Any import requests -from django.contrib.staticfiles import finders -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from bridgebloc.apps.tokens.models import Token from bridgebloc.evm.types import ChainID +logger = logging.getLogger(__name__) + class Command(BaseCommand): - help = 'Fetches and stores token information from Coingecko into the database' # noqa: A003 + """Fetches and stores token information from Coingecko into the database.""" + help = 'Fetches and stores token information from Coingecko' # noqa: A003 SUPPORTED_COINGECKO_IDS = ('usd-coin', 'dai', 'tether', 'weth') + IMAGE_BASE_URL = 'https://raw.githubusercontent.com/SmolDapp/tokenAssets/main/tokens/{}/{}/logo-128.png' def handle(self, *args: Any, **options: Any) -> None: # noqa: ARG002 + try: + self.fetch_and_store_mainnet_tokens() + except Exception: + logger.exception('Error in fetch_and_store_mainnet_tokens') + + try: + self.populate_testnet_tokens() + except Exception: + logger.exception('Error in populate_testnet_tokens') + + def fetch_and_store_mainnet_tokens(self) -> None: + """Fetches mainnet token data from Coingecko and stores it in the database.""" tokens_to_create = [] + for coingecko_id in self.SUPPORTED_COINGECKO_IDS: try: - token_data = self._fetch_mainnet_token_data(coingecko_id) - tokens_to_create.extend(self._extract_tokens(token_data)) - except Exception as e: # noqa: BLE001 - raise CommandError(f'Error fetching token information for {coingecko_id}: {e}') from e + url = f'https://api.coingecko.com/api/v3/coins/{coingecko_id}' + response = requests.get(url, timeout=10) + response.raise_for_status() + token_data = response.json() - Token.objects.bulk_create(tokens_to_create, ignore_conflicts=True) + for chain_id in ChainID: + if not chain_id.is_mainnet(): + continue - self._populate_testnet_token_data() + platform_data = token_data['detail_platforms'][chain_id.to_coingecko_id()] + address = platform_data['contract_address'] + image_url = self.IMAGE_BASE_URL.format(chain_id.value, address) - @staticmethod - def _populate_testnet_token_data() -> None: - token_addresses: dict[ChainID, list[tuple[str, str]]] = { - ChainID.ETHEREUM_TESTNET: [ - ('0x07865c6E87B9F70255377e024ace6630C1Eaa37F', 'usd-coin'), - ], - ChainID.ARBITRUM_ONE_TESTNET: [ - ('0xfd064A18f3BF249cf1f87FC203E90D8f650f2d63', 'usd-coin'), - ], - ChainID.POLYGON_POS_TESTNET: [ - ('0x0FA8781a83E46826621b3BC094Ea2A0212e71B23', 'usd-coin'), - ], - ChainID.AVALANCHE_TESTNET: [ - ('0x5425890298aed601595a70AB815c96711a31Bc65', 'usd-coin'), - ], - ChainID.POLYGON_ZKEVM_TESTNET: [ - ('0xA40b0dA87577Cd224962e8A3420631E1C4bD9A9f', 'usd-coin'), - ], - } - testnet_tokens = [] - for chain_id, tokens in token_addresses.items(): - for token in tokens: - mainnet_token = Token.objects.filter(coingecko_id=token[1]).first() - if mainnet_token: - testnet_tokens.append( + tokens_to_create.append( Token( - name=mainnet_token.name, - symbol=mainnet_token.symbol, chain_id=chain_id, - decimals=mainnet_token.decimals, - coingecko_id=mainnet_token.coingecko_id, - address=token[0], + name=token_data['name'], + symbol=token_data['symbol'], + coingecko_id=token_data['id'], + decimals=platform_data['decimal_place'], + address=address, + image_url=image_url, ), ) - Token.objects.bulk_create(testnet_tokens, ignore_conflicts=True) + except Exception: + logger.exception(f'Error fetching token information for {coingecko_id}') + + if tokens_to_create: + logger.warning('No mainnet tokens were created.') + + Token.objects.bulk_create(tokens_to_create, ignore_conflicts=True) @staticmethod - def _fetch_mainnet_token_data(coingecko_id: str) -> dict[str, Any]: - """Fetches token data from the Coingecko API.""" - url = f'https://api.coingecko.com/api/v3/coins/{coingecko_id}' - response = requests.get(url, timeout=10) - response.raise_for_status() - return response.json() - - def _extract_tokens(self, token_data: dict[str, Any]) -> list[Token]: - """Extracts tokens from token data.""" - tokens = [] - for chain_id in ChainID: - if not chain_id.is_mainnet(): - continue - - if chain_id == ChainID.POLYGON_ZKEVM and token_data['id'] == 'weth': - continue - - tokens.append( - Token( - chain_id=chain_id, - name=token_data['name'], - symbol=token_data['symbol'], - coingecko_id=token_data['id'], - decimals=token_data['detail_platforms'][chain_id.to_coingecko_id()]['decimal_place'], - address=token_data['detail_platforms'][chain_id.to_coingecko_id()]['contract_address'], - ), - ) + def populate_testnet_tokens() -> None: + """Populates testnet token data based on mainnet tokens.""" + testnet_data = { + ChainID.ETHEREUM_TESTNET: [('0x07865c6E87B9F70255377e024ace6630C1Eaa37F', 'usd-coin')], + ChainID.ARBITRUM_ONE_TESTNET: [('0xfd064A18f3BF249cf1f87FC203E90D8f650f2d63', 'usd-coin')], + ChainID.POLYGON_POS_TESTNET: [('0x0FA8781a83E46826621b3BC094Ea2A0212e71B23', 'usd-coin')], + ChainID.AVALANCHE_TESTNET: [('0x5425890298aed601595a70AB815c96711a31Bc65', 'usd-coin')], + ChainID.BASE_TESTNET: [('0x036CbD53842c5426634e7929541eC2318f3dCF7e', 'usd-coin')], + ChainID.OPTIMISM_TESTNET: [('0x5fd84259d66Cd46123540766Be93DFE6D43130D7', 'usd-coin')], + } - self._save_token_image(token_data['id'], token_data['image']['large']) + testnet_tokens = [ + Token( + name=mainnet_token.name, + symbol=mainnet_token.symbol, + chain_id=testnet_chain_id, + decimals=mainnet_token.decimals, + coingecko_id=mainnet_token.coingecko_id, + address=address, + image_url=mainnet_token.image_url, + ) + for testnet_chain_id, tokens in testnet_data.items() + for address, coingecko_id in tokens + if (mainnet_token := Token.objects.filter(coingecko_id=coingecko_id).first()) + ] - return tokens + if not testnet_tokens: + logger.warning('No testnet tokens were created.') - @staticmethod - def _save_token_image(coingecko_id: str, url: str) -> None: - """Save a token image to the ``tokens`` static directory.""" - result = finders.find(f'images/{coingecko_id}.png') - if result: - return - - image_path = Path(__file__).resolve().parent.parent.parent / 'static' / 'images' / f'{coingecko_id}.png' - response = requests.get(url, timeout=10) - response.raise_for_status() - with image_path.open('wb') as f: - f.write(response.content) + Token.objects.bulk_create(testnet_tokens, ignore_conflicts=True) diff --git a/backend/bridgebloc/apps/tokens/models.py b/backend/bridgebloc/apps/tokens/models.py index 9a5afae..2fa4185 100644 --- a/backend/bridgebloc/apps/tokens/models.py +++ b/backend/bridgebloc/apps/tokens/models.py @@ -15,6 +15,7 @@ class Token(UUIDModel, TimestampedModel, models.Model): symbol = models.CharField('symbol', max_length=200, blank=False) chain_id = EVMChainIDField('chain id', blank=False) decimals = models.IntegerField('decimals', blank=False) + image_url = models.URLField('image url', blank=False) coingecko_id = models.CharField('coingecko id', max_length=200, blank=False) address = EVMAddressField('address', blank=False, unique=True) diff --git a/backend/bridgebloc/conf/settings.py b/backend/bridgebloc/conf/settings.py index c3aa2fc..93c09b2 100644 --- a/backend/bridgebloc/conf/settings.py +++ b/backend/bridgebloc/conf/settings.py @@ -264,21 +264,15 @@ }, } + # ============================================================================== -# CIRCLE API SETTINGS +# CCTP SETTINGS # ============================================================================== -CIRCLE_MASTER_WALLET_ID = env.int('CIRCLE_MASTER_WALLET_ID') -CIRCLE_SANDBOX_API_KEY = env.str('CIRCLE_SANDBOX_API_KEY') -CIRCLE_SANDBOX_BASE_URL = env.str('CIRCLE_SANDBOX_BASE_URL') -CIRCLE_LIVE_API_KEY = env.str('CIRCLE_LIVE_API_KEY') -CIRCLE_LIVE_BASE_URL = env.str('CIRCLE_LIVE_BASE_URL') +# Attestation Service CIRCLE_ATTESTATION_BASE_URL = env.str('CIRCLE_ATTESTATION_BASE_URL') CIRCLE_SANDBOX_ATTESTATION_BASE_URL = env.str('CIRCLE_SANDBOX_ATTESTATION_BASE_URL') - -# ============================================================================== -# CCTP SETTINGS -# ============================================================================== +# Rpc ETHEREUM_RPC_NODES = env.list('ETHEREUM_RPC_NODES') POLYGON_POS_RPC_NODES = env.list('POLYGON_POS_RPC_NODES') ARBITRUM_ONE_RPC_NODES = env.list('ARBITRUM_ONE_RPC_NODES') diff --git a/backend/bridgebloc/evm/aggregator.py b/backend/bridgebloc/evm/aggregator.py index 98d7633..2ec9950 100644 --- a/backend/bridgebloc/evm/aggregator.py +++ b/backend/bridgebloc/evm/aggregator.py @@ -13,6 +13,10 @@ class EVMAggregatorConfig: # pylint:disable=too-many-instance-attributes avalanche_testnet_endpoints: list[str] arbitrum_one_endpoints: list[str] arbitrum_one_testnet_endpoints: list[str] + base_endpoints: list[str] + base_testnet_endpoints: list[str] + optimism_endpoints: list[str] + optimism_testnet_endpoints: list[str] polygon_pos_endpoints: list[str] polygon_pos_testnet_endpoints: list[str] diff --git a/backend/bridgebloc/evm/types.py b/backend/bridgebloc/evm/types.py index ec67a86..3c52643 100644 --- a/backend/bridgebloc/evm/types.py +++ b/backend/bridgebloc/evm/types.py @@ -3,14 +3,18 @@ @unique class ChainID(IntEnum): + BASE = 8453 ETHEREUM = 1 - AVALANCHE = 43114 + OPTIMISM = 10 POLYGON_POS = 137 + AVALANCHE = 43114 + BASE_TESTNET = 84532 ARBITRUM_ONE = 42161 ETHEREUM_TESTNET = 5 - ARBITRUM_ONE_TESTNET = 421613 AVALANCHE_TESTNET = 43113 POLYGON_POS_TESTNET = 80001 + OPTIMISM_TESTNET = 11155420 + ARBITRUM_ONE_TESTNET = 421613 @classmethod def values(cls) -> list[int]: @@ -27,6 +31,9 @@ def to_coingecko_id(self) -> str: if not self.is_mainnet(): raise ValueError('Cannot convert testnet blockchain to CoinGecko ID') + if self == ChainID.OPTIMISM: + return 'optimistic-ethereum' + return self.name.lower().replace('_', '-') @classmethod @@ -47,6 +54,15 @@ def to_cctp_domain(self) -> int: if self in {ChainID.ARBITRUM_ONE, ChainID.ARBITRUM_ONE_TESTNET}: return 3 + if self in {ChainID.BASE, ChainID.BASE_TESTNET}: + return 6 + + if self in {ChainID.POLYGON_POS, ChainID.POLYGON_POS_TESTNET}: + return 7 + + if self in {ChainID.OPTIMISM, ChainID.OPTIMISM_TESTNET}: + return 2 + raise ValueError(f'{self} is not supported by CCTP') def is_valid_cctp_chain(self) -> bool: @@ -57,15 +73,5 @@ def is_valid_cctp_chain(self) -> bool: return True - def to_circle(self) -> str: - if self.name.startswith('ETHEREUM'): - return 'ETH' - if self.name.startswith('AVALANCHE'): - return 'AVAX' - if self.name.startswith('POLYGON_POS'): - return 'MATIC' - - raise ValueError(f'Circle API does not support {self.name}') - def __str__(self) -> str: return self.name.lower() diff --git a/backend/bridgebloc/services/circle.py b/backend/bridgebloc/services/circle.py deleted file mode 100644 index 0a1ad4e..0000000 --- a/backend/bridgebloc/services/circle.py +++ /dev/null @@ -1,102 +0,0 @@ -import uuid -from decimal import Decimal -from typing import Any, Literal - -import requests -from eth_utils.address import to_checksum_address - - -class CircleAPI: - def __init__(self, api_key: str, base_url: str) -> None: - self.api_key = api_key - self.base_url = base_url - self.session = requests.Session() - - def _build_url(self, endpoint: str) -> str: - return f'{self.base_url}/{endpoint}' - - def _request( - self, - method: Literal['GET', 'POST'], - endpoint: str, - params: dict[str, Any] | None = None, - data: dict[str, Any] | None = None, - ) -> requests.Response: - headers = { - 'Authorization': f'Bearer {self.api_key}', - 'Content-Type': 'application/json', - } - url = self._build_url(endpoint) - response = self.session.request(method, url, params=params, json=data, headers=headers) - response.raise_for_status() - - return response - - def ping(self) -> bool: - response = self._request('GET', 'ping') - return response.json().get('message') == 'pong' - - def create_payment_intent(self, amount: Decimal, chain: str) -> dict[str, Any]: - response = self._request( - method='POST', - endpoint='v1/paymentIntents', - data={ - 'idempotencyKey': str(uuid.uuid4()), - 'amount': {'amount': str(amount), 'currency': 'USD'}, - 'settlementCurrency': 'USD', - 'paymentMethods': [{'type': 'blockchain', 'chain': chain}], - }, - ) - return response.json() - - def get_payment_intent(self, payment_intent_id: str) -> dict[str, Any]: - response = self._request( - method='GET', - endpoint=f'v1/paymentIntents/{payment_intent_id}', - ) - return response.json() - - def make_withdrawal( - self, - amount: Decimal, - master_wallet_id: int, - destination_address: str, - chain: str, - ) -> dict[str, Any]: - response = self._request( - method='POST', - endpoint='v1/transfers', - data={ - 'idempotencyKey': str(uuid.uuid4()), - 'source': { - 'type': 'wallet', - 'id': str(master_wallet_id), - }, - 'destination': { - 'type': 'blockchain', - 'address': to_checksum_address(destination_address), - 'chain': chain, - }, - 'amount': {'amount': f'{amount:.2f}', 'currency': 'USD'}, - }, - ) - return response.json() - - def get_withdrawal_info(self, withdrawal_id: str) -> dict[str, Any]: - response = self._request( - method='GET', - endpoint=f'v1/transfers/{withdrawal_id}', - ) - return response.json() - - def add_recipient(self, address: str, chain: str) -> dict[str, Any]: - response = self._request( - method='POST', - endpoint='v1/addressBook/recipients', - data={ - 'chain': chain, - 'idempotencyKey': str(uuid.uuid4()), - 'address': to_checksum_address(address), - }, - ) - return response.json()