diff --git a/pyproject.toml b/pyproject.toml index f6ec88e606..56fb9c0a1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "celery-redbeat==2.3.3", "channels>=4.1.0,<5.0.0", "channels-redis>=4.2.0,<5.0.0", + "circle-developer-controlled-wallets>=9.2.0,<10.0.0", "cloudscraper>=1.2.60,<2.0.0", "daphne>=4.1.2,<5.0.0", "dj-rest-auth[with-social]>=7.0.2,<8.0.0", diff --git a/src/config/ci/keys.py b/src/config/ci/keys.py index bfa1a3c739..e9d34544e0 100644 --- a/src/config/ci/keys.py +++ b/src/config/ci/keys.py @@ -76,3 +76,7 @@ RESEARCHHUB_JOURNAL_ID = os.environ.get("RESEARCHHUB_JOURNAL_ID", "") SCRAPER_URL = os.environ.get("SCRAPER_URL", "") + +CIRCLE_API_KEY = os.environ.get("CIRCLE_API_KEY", "") +CIRCLE_ENTITY_SECRET = os.environ.get("CIRCLE_ENTITY_SECRET", "") +CIRCLE_WALLET_SET_ID = os.environ.get("CIRCLE_WALLET_SET_ID", "") diff --git a/src/config/keys.py b/src/config/keys.py index 1cf50b5d8f..c51519bd95 100644 --- a/src/config/keys.py +++ b/src/config/keys.py @@ -74,3 +74,7 @@ RESEARCHHUB_JOURNAL_ID = os.environ.get("RESEARCHHUB_JOURNAL_ID", "") SCRAPER_URL = os.environ.get("SCRAPER_URL", "") + +CIRCLE_API_KEY = os.environ.get("CIRCLE_API_KEY", "") +CIRCLE_ENTITY_SECRET = os.environ.get("CIRCLE_ENTITY_SECRET", "") +CIRCLE_WALLET_SET_ID = os.environ.get("CIRCLE_WALLET_SET_ID", "") diff --git a/src/purchase/circle/__init__.py b/src/purchase/circle/__init__.py new file mode 100644 index 0000000000..4fa7841d9d --- /dev/null +++ b/src/purchase/circle/__init__.py @@ -0,0 +1,7 @@ +from purchase.circle.client import CircleWalletClient +from purchase.circle.service import CircleWalletService + +__all__ = [ + "CircleWalletClient", + "CircleWalletService", +] diff --git a/src/purchase/circle/client.py b/src/purchase/circle/client.py new file mode 100644 index 0000000000..1fbd291b9d --- /dev/null +++ b/src/purchase/circle/client.py @@ -0,0 +1,116 @@ +import logging +import uuid +from dataclasses import dataclass + +from circle.web3 import utils as circle_utils +from circle.web3.developer_controlled_wallets.api import WalletsApi +from circle.web3.developer_controlled_wallets.models import ( + AccountType, + Blockchain, + CreateWalletRequest, +) +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class CircleWalletCreationError(Exception): + """Raised when Circle fails to create a wallet.""" + + pass + + +class CircleWalletFrozenError(Exception): + """Raised when a Circle wallet is in FROZEN state.""" + + pass + + +@dataclass +class CircleWalletResult: + """Result of fetching a Circle wallet.""" + + wallet_id: str + address: str + state: str + + +class CircleWalletClient: + """ + Low-level client wrapping the Circle developer-controlled wallets SDK. + + Handles SDK initialization and provides methods for creating and + fetching wallets. + """ + + def __init__(self): + self._wallets_api = None + + @property + def wallets_api(self): + if self._wallets_api is None: + api_client = circle_utils.init_developer_controlled_wallets_client( + api_key=settings.CIRCLE_API_KEY, + entity_secret=settings.CIRCLE_ENTITY_SECRET, + ) + self._wallets_api = WalletsApi(api_client) + return self._wallets_api + + def create_wallet(self, idempotency_key: str | None = None) -> str: + """ + Request creation of a new SCA wallet on ETH and BASE. + + Circle wallet creation may be asynchronous. This method returns the + wallet ID; the caller should use `get_wallet()` to check if the + wallet is LIVE and retrieve the on-chain address. + + Args: + idempotency_key: Key to prevent duplicate wallet creation on + retries. Auto-generated if not provided. + + Returns: + The Circle wallet ID (UUID string). + + Raises: + CircleWalletCreationError: If the API returns no wallets. + """ + if not idempotency_key: + idempotency_key = uuid.uuid4().hex + + request = CreateWalletRequest( + idempotencyKey=idempotency_key, + blockchains=[Blockchain.ETH, Blockchain.BASE], + walletSetId=settings.CIRCLE_WALLET_SET_ID, + accountType=AccountType.SCA, + count=1, + ) + + response = self.wallets_api.create_wallet(request) + wallets = response.data.wallets + + if not wallets: + raise CircleWalletCreationError( + "Circle API returned no wallets in creation response" + ) + + wallet = wallets[0].actual_instance + return wallet.id + + def get_wallet(self, wallet_id: str) -> CircleWalletResult: + """ + Fetch the current state of a Circle wallet. + + Args: + wallet_id: The Circle wallet UUID. + + Returns: + CircleWalletResult with wallet details. + """ + response = self.wallets_api.get_wallet(wallet_id) + wallet = response.data.wallet.actual_instance + + return CircleWalletResult( + wallet_id=wallet.id, + address=wallet.address, + state=wallet.state.value, + ) diff --git a/src/purchase/circle/service.py b/src/purchase/circle/service.py new file mode 100644 index 0000000000..07be27b6b1 --- /dev/null +++ b/src/purchase/circle/service.py @@ -0,0 +1,104 @@ +import logging +from dataclasses import dataclass +from typing import Optional + +from django.db import transaction + +from purchase.circle.client import CircleWalletClient, CircleWalletFrozenError +from purchase.models import Wallet + +logger = logging.getLogger(__name__) + + +@dataclass +class DepositAddressResult: + """Result of requesting a user's deposit address.""" + + address: str + provisioning: bool = False + + +class CircleWalletService: + """ + Service for lazy Circle wallet provisioning. + + When a user requests a deposit address, this service either returns their + existing Circle wallet address or provisions a new one via the Circle API. + """ + + def __init__(self, client: Optional[CircleWalletClient] = None): + self.client = client or CircleWalletClient() + + def get_or_create_deposit_address(self, user) -> DepositAddressResult: + """ + Get (or provision) the Circle deposit address for a user. + + Flow: + 1. If the wallet already has an address with a Circle wallet_type, + return it immediately. + 2. If user has a circle_wallet_id but no address, fetch from Circle. + 3. If user has neither, create a new Circle wallet and fetch. + + Args: + user: The authenticated Django User instance. + + Returns: + DepositAddressResult with the on-chain address. + + Raises: + CircleWalletFrozenError: If the wallet is not in LIVE state. + CircleWalletCreationError: If Circle API fails. + """ + # Phase 1: Lock the wallet row and determine what action is needed. + # If we need to create a Circle wallet, do it inside this transaction + # so the wallet_id is persisted even if polling later fails. + with transaction.atomic(): + wallet, _ = Wallet.objects.select_for_update().get_or_create(user=user) + + # Already fully provisioned + if wallet.circle_wallet_id and wallet.address: + return DepositAddressResult(address=wallet.address) + + # No Circle wallet yet — create one and persist the ID + if not wallet.circle_wallet_id: + self._create_wallet(wallet) + + # Phase 2: Fetch the address outside the transaction so that + # a CircleWalletFrozenError does NOT roll back the wallet_id save. + return self._fetch_and_store_address(wallet) + + def _create_wallet(self, wallet: Wallet) -> None: + """Create a new Circle wallet and store the wallet ID.""" + idempotency_key = f"rh-wallet-{wallet.pk}" + wallet_id = self.client.create_wallet(idempotency_key=idempotency_key) + + wallet.circle_wallet_id = wallet_id + wallet.wallet_type = Wallet.WALLET_TYPE_CIRCLE + wallet.save(update_fields=["circle_wallet_id", "wallet_type"]) + + logger.info( + "Circle wallet created for wallet pk=%s, circle_wallet_id=%s", + wallet.pk, + wallet_id, + ) + + def _fetch_and_store_address(self, wallet: Wallet) -> DepositAddressResult: + """Fetch wallet state from Circle. Store address if LIVE.""" + result = self.client.get_wallet(wallet.circle_wallet_id) + + if result.state != "LIVE": + raise CircleWalletFrozenError( + f"Wallet {wallet.circle_wallet_id} is in state " + f"'{result.state}', expected LIVE" + ) + + wallet.address = result.address + wallet.save(update_fields=["address"]) + + logger.info( + "Circle address stored for wallet pk=%s: %s", + wallet.pk, + result.address, + ) + + return DepositAddressResult(address=result.address) diff --git a/src/purchase/circle/tests/__init__.py b/src/purchase/circle/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/purchase/circle/tests/test_client.py b/src/purchase/circle/tests/test_client.py new file mode 100644 index 0000000000..d05202c856 --- /dev/null +++ b/src/purchase/circle/tests/test_client.py @@ -0,0 +1,103 @@ +from unittest.mock import Mock, patch + +from django.test import TestCase + +from purchase.circle.client import CircleWalletClient, CircleWalletCreationError + + +class TestCircleWalletClient(TestCase): + """Tests for CircleWalletClient.""" + + def _make_client(self, mock_wallets_api): + """Create a CircleWalletClient with a mocked WalletsApi.""" + client = CircleWalletClient.__new__(CircleWalletClient) + client._wallets_api = mock_wallets_api + return client + + def _make_wallet_instance(self, **kwargs): + """Create a mock wallet actual_instance.""" + wallet = Mock() + wallet.id = kwargs.get("id", "wallet-uuid-1") + wallet.address = kwargs.get("address", "0xABC123") + wallet.state = kwargs.get("state", Mock(value="LIVE")) + return wallet + + def test_create_wallet_returns_wallet_id(self): + mock_api = Mock() + wallet_instance = self._make_wallet_instance(id="circle-wallet-uuid-1") + wallet_wrapper = Mock() + wallet_wrapper.actual_instance = wallet_instance + + mock_api.create_wallet.return_value = Mock(data=Mock(wallets=[wallet_wrapper])) + + client = self._make_client(mock_api) + wallet_id = client.create_wallet(idempotency_key="test-key-1") + + self.assertEqual(wallet_id, "circle-wallet-uuid-1") + mock_api.create_wallet.assert_called_once() + + def test_create_wallet_empty_response_raises(self): + mock_api = Mock() + mock_api.create_wallet.return_value = Mock(data=Mock(wallets=[])) + + client = self._make_client(mock_api) + + with self.assertRaises(CircleWalletCreationError): + client.create_wallet() + + def test_create_wallet_generates_idempotency_key_when_none(self): + mock_api = Mock() + wallet_instance = self._make_wallet_instance() + wallet_wrapper = Mock() + wallet_wrapper.actual_instance = wallet_instance + mock_api.create_wallet.return_value = Mock(data=Mock(wallets=[wallet_wrapper])) + + client = self._make_client(mock_api) + client.create_wallet() + + # Verify create_wallet was called (idempotency key auto-generated) + call_args = mock_api.create_wallet.call_args + request = call_args[0][0] + self.assertIsNotNone(request.idempotency_key) + + def test_get_wallet_live_returns_result(self): + mock_api = Mock() + live_state = Mock(value="LIVE") + # WalletState.LIVE comparison + from circle.web3.developer_controlled_wallets.models import WalletState + + wallet_instance = Mock() + wallet_instance.id = "wallet-1" + wallet_instance.address = "0xabc123" + wallet_instance.state = WalletState.LIVE + + wallet_wrapper = Mock() + wallet_wrapper.actual_instance = wallet_instance + mock_api.get_wallet.return_value = Mock(data=Mock(wallet=wallet_wrapper)) + + client = self._make_client(mock_api) + result = client.get_wallet("wallet-1") + + self.assertEqual(result.wallet_id, "wallet-1") + self.assertEqual(result.address, "0xabc123") + self.assertEqual(result.state, "LIVE") + + def test_get_wallet_not_live_returns_result_with_state(self): + mock_api = Mock() + from circle.web3.developer_controlled_wallets.models import WalletState + + wallet_instance = Mock() + wallet_instance.id = "wallet-1" + wallet_instance.address = "" + wallet_instance.state = WalletState.FROZEN + + wallet_wrapper = Mock() + wallet_wrapper.actual_instance = wallet_instance + mock_api.get_wallet.return_value = Mock(data=Mock(wallet=wallet_wrapper)) + + client = self._make_client(mock_api) + result = client.get_wallet("wallet-1") + + self.assertEqual(result.wallet_id, "wallet-1") + self.assertEqual(result.address, "") + self.assertEqual(result.state, "FROZEN") diff --git a/src/purchase/circle/tests/test_service.py b/src/purchase/circle/tests/test_service.py new file mode 100644 index 0000000000..e8adc08c9b --- /dev/null +++ b/src/purchase/circle/tests/test_service.py @@ -0,0 +1,150 @@ +from unittest.mock import Mock + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from purchase.circle.client import ( + CircleWalletCreationError, + CircleWalletFrozenError, + CircleWalletResult, +) +from purchase.circle.service import CircleWalletService +from purchase.models import Wallet + +User = get_user_model() + + +class TestCircleWalletService(TestCase): + """Tests for CircleWalletService.""" + + def setUp(self): + self.mock_client = Mock() + self.service = CircleWalletService(client=self.mock_client) + self.user = User.objects.create_user(username="testUser1") + + def test_returns_existing_address(self): + """When address and circle_wallet_id are set, return without calling Circle.""" + wallet = Wallet.objects.create( + user=self.user, + address="0xExistingAddress", + circle_wallet_id="existing-id", + wallet_type=Wallet.WALLET_TYPE_CIRCLE, + ) + + result = self.service.get_or_create_deposit_address(self.user) + + self.assertEqual(result.address, "0xExistingAddress") + self.assertFalse(result.provisioning) + self.mock_client.create_wallet.assert_not_called() + self.mock_client.get_wallet.assert_not_called() + + def test_polls_when_wallet_id_exists_but_no_address(self): + """When circle_wallet_id exists but no address, poll Circle.""" + wallet = Wallet.objects.create( + user=self.user, + circle_wallet_id="pending-wallet-id", + wallet_type=Wallet.WALLET_TYPE_CIRCLE, + ) + + self.mock_client.get_wallet.return_value = CircleWalletResult( + wallet_id="pending-wallet-id", + address="0xNewAddress", + state="LIVE", + ) + + result = self.service.get_or_create_deposit_address(self.user) + + self.assertEqual(result.address, "0xNewAddress") + self.mock_client.create_wallet.assert_not_called() + self.mock_client.get_wallet.assert_called_once_with("pending-wallet-id") + + wallet.refresh_from_db() + self.assertEqual(wallet.address, "0xNewAddress") + + def test_creates_wallet_record_and_circle_wallet_when_none_exists(self): + """When user has no wallet record at all, create both DB and Circle wallet.""" + self.mock_client.create_wallet.return_value = "new-circle-wallet-id" + self.mock_client.get_wallet.return_value = CircleWalletResult( + wallet_id="new-circle-wallet-id", + address="0xBrandNewAddress", + state="LIVE", + ) + + result = self.service.get_or_create_deposit_address(self.user) + + self.assertEqual(result.address, "0xBrandNewAddress") + + wallet = Wallet.objects.get(user=self.user) + self.mock_client.create_wallet.assert_called_once_with( + idempotency_key=f"rh-wallet-{wallet.pk}" + ) + self.assertEqual(wallet.circle_wallet_id, "new-circle-wallet-id") + self.assertEqual(wallet.address, "0xBrandNewAddress") + self.assertEqual(wallet.wallet_type, Wallet.WALLET_TYPE_CIRCLE) + + def test_creates_circle_wallet_when_empty_wallet_exists(self): + """When user has an empty wallet record, create Circle wallet.""" + wallet = Wallet.objects.create(user=self.user) + + self.mock_client.create_wallet.return_value = "new-id" + self.mock_client.get_wallet.return_value = CircleWalletResult( + wallet_id="new-id", + address="0xAddr", + state="LIVE", + ) + + result = self.service.get_or_create_deposit_address(self.user) + + self.assertEqual(result.address, "0xAddr") + self.mock_client.create_wallet.assert_called_once_with( + idempotency_key=f"rh-wallet-{wallet.pk}" + ) + + wallet.refresh_from_db() + self.assertEqual(wallet.circle_wallet_id, "new-id") + self.assertEqual(wallet.address, "0xAddr") + + def test_raises_not_live_when_wallet_frozen(self): + """When wallet is FROZEN, raise error. Wallet ID is saved.""" + self.mock_client.create_wallet.return_value = "frozen-wallet-id" + self.mock_client.get_wallet.return_value = CircleWalletResult( + wallet_id="frozen-wallet-id", + address="", + state="FROZEN", + ) + + with self.assertRaises(CircleWalletFrozenError): + self.service.get_or_create_deposit_address(self.user) + + wallet = Wallet.objects.get(user=self.user) + self.assertEqual(wallet.circle_wallet_id, "frozen-wallet-id") + self.assertIsNone(wallet.address) + + def test_raises_creation_error_on_api_failure(self): + """When Circle API fails, raise CircleWalletCreationError.""" + self.mock_client.create_wallet.side_effect = CircleWalletCreationError( + "API error" + ) + + with self.assertRaises(CircleWalletCreationError): + self.service.get_or_create_deposit_address(self.user) + + def test_raises_not_live_when_existing_wallet_frozen(self): + """When wallet_id exists but wallet is FROZEN, raise error.""" + wallet = Wallet.objects.create( + user=self.user, + circle_wallet_id="frozen-id", + wallet_type=Wallet.WALLET_TYPE_CIRCLE, + ) + + self.mock_client.get_wallet.return_value = CircleWalletResult( + wallet_id="frozen-id", + address="", + state="FROZEN", + ) + + with self.assertRaises(CircleWalletFrozenError): + self.service.get_or_create_deposit_address(self.user) + + wallet.refresh_from_db() + self.assertIsNone(wallet.address) diff --git a/src/purchase/migrations/0046_create_wallet.py b/src/purchase/migrations/0046_create_wallet.py new file mode 100644 index 0000000000..2745b98832 --- /dev/null +++ b/src/purchase/migrations/0046_create_wallet.py @@ -0,0 +1,51 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("purchase", "0045_remove_wallet_model"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Wallet", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("address", models.CharField(max_length=255, null=True)), + ( + "circle_wallet_id", + models.CharField( + blank=True, max_length=255, null=True, unique=True + ), + ), + ( + "wallet_type", + models.CharField( + choices=[("EXTERNAL", "External"), ("CIRCLE", "Circle")], + default="EXTERNAL", + max_length=20, + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="wallet", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/src/purchase/models.py b/src/purchase/models.py index 79264607e4..0215af835a 100644 --- a/src/purchase/models.py +++ b/src/purchase/models.py @@ -10,6 +10,7 @@ from .related_models.rsc_purchase_fee import RscPurchaseFee from .related_models.support_model import Support from .related_models.usd_fundraise_contribution_model import UsdFundraiseContribution +from .related_models.wallet_model import Wallet migratables = ( AggregatePurchase, @@ -24,4 +25,5 @@ RscPurchaseFee, Support, UsdFundraiseContribution, + Wallet, ) diff --git a/src/purchase/related_models/wallet_model.py b/src/purchase/related_models/wallet_model.py new file mode 100644 index 0000000000..d3c45a5715 --- /dev/null +++ b/src/purchase/related_models/wallet_model.py @@ -0,0 +1,30 @@ +from django.db import models + + +class Wallet(models.Model): + WALLET_TYPE_EXTERNAL = "EXTERNAL" + WALLET_TYPE_CIRCLE = "CIRCLE" + WALLET_TYPE_CHOICES = [ + (WALLET_TYPE_EXTERNAL, "External"), + (WALLET_TYPE_CIRCLE, "Circle"), + ] + + user = models.OneToOneField( + "user.User", + related_name="wallet", + on_delete=models.CASCADE, + ) + address = models.CharField(max_length=255, null=True) + + # Circle developer-controlled wallet fields + circle_wallet_id = models.CharField( + max_length=255, + null=True, + blank=True, + unique=True, + ) + wallet_type = models.CharField( + max_length=20, + choices=WALLET_TYPE_CHOICES, + default=WALLET_TYPE_EXTERNAL, + ) diff --git a/src/purchase/serializers/__init__.py b/src/purchase/serializers/__init__.py index 9e2eb061de..6331811ca7 100644 --- a/src/purchase/serializers/__init__.py +++ b/src/purchase/serializers/__init__.py @@ -12,3 +12,4 @@ ) from .rsc_exchange_serializer import RscExchangeRateSerializer from .support_serializer import SupportSerializer +from .wallet_serializer import WalletSerializer diff --git a/src/purchase/serializers/wallet_serializer.py b/src/purchase/serializers/wallet_serializer.py new file mode 100644 index 0000000000..0f552dc6cd --- /dev/null +++ b/src/purchase/serializers/wallet_serializer.py @@ -0,0 +1,10 @@ +import rest_framework.serializers as serializers + +from purchase.models import ( + Wallet, +) + +class WalletSerializer(serializers.ModelSerializer): + class Meta: + model = Wallet + fields = "__all__" diff --git a/src/purchase/tests/test_circle_wallet_view.py b/src/purchase/tests/test_circle_wallet_view.py new file mode 100644 index 0000000000..ea21c7b929 --- /dev/null +++ b/src/purchase/tests/test_circle_wallet_view.py @@ -0,0 +1,86 @@ +from unittest.mock import Mock + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIRequestFactory, force_authenticate + +from purchase.circle.client import CircleWalletCreationError, CircleWalletFrozenError +from purchase.circle.service import DepositAddressResult +from purchase.views import DepositAddressView + +User = get_user_model() + + +class TestDepositAddressView(TestCase): + """Tests for the DepositAddressView.""" + + def setUp(self): + self.factory = APIRequestFactory() + self.service_mock = Mock() + self.user = User.objects.create_user(username="user1") + + def test_returns_existing_address(self): + """Test 200 when address is already provisioned.""" + self.service_mock.get_or_create_deposit_address.return_value = ( + DepositAddressResult(address="0xABC123") + ) + + request = self.factory.get("/api/wallet/deposit-address/") + force_authenticate(request, user=self.user) + + response = DepositAddressView.as_view()(request, service=self.service_mock) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["address"], "0xABC123") + self.assertFalse(response.data["provisioning"]) + + def test_returns_500_when_wallet_frozen(self): + """Test 500 when wallet is frozen.""" + self.service_mock.get_or_create_deposit_address.side_effect = ( + CircleWalletFrozenError("Wallet is FROZEN") + ) + + request = self.factory.get("/api/wallet/deposit-address/") + force_authenticate(request, user=self.user) + + response = DepositAddressView.as_view()(request, service=self.service_mock) + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + def test_returns_500_on_creation_error(self): + """Test 500 when Circle API fails.""" + self.service_mock.get_or_create_deposit_address.side_effect = ( + CircleWalletCreationError("API down") + ) + + request = self.factory.get("/api/wallet/deposit-address/") + force_authenticate(request, user=self.user) + + response = DepositAddressView.as_view()(request, service=self.service_mock) + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + def test_returns_500_on_unexpected_error(self): + """Test 500 on unexpected exceptions.""" + self.service_mock.get_or_create_deposit_address.side_effect = RuntimeError( + "Unexpected" + ) + + request = self.factory.get("/api/wallet/deposit-address/") + force_authenticate(request, user=self.user) + + response = DepositAddressView.as_view()(request, service=self.service_mock) + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + def test_unauthenticated_returns_403(self): + """Test that unauthenticated requests are rejected.""" + request = self.factory.get("/api/wallet/deposit-address/") + + response = DepositAddressView.as_view()(request) + + self.assertIn( + response.status_code, + [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], + ) diff --git a/src/purchase/views/__init__.py b/src/purchase/views/__init__.py index 5d1c5417c9..9af5c24c3c 100644 --- a/src/purchase/views/__init__.py +++ b/src/purchase/views/__init__.py @@ -1,5 +1,6 @@ from .balance_view import BalanceViewSet from .checkout_view import CheckoutView +from .circle_wallet_view import DepositAddressView from .coinbase_view import CoinbaseViewSet from .endaoment_auth_views import ( EndaomentCallbackView, diff --git a/src/purchase/views/circle_wallet_view.py b/src/purchase/views/circle_wallet_view.py new file mode 100644 index 0000000000..3b5a3bb7d1 --- /dev/null +++ b/src/purchase/views/circle_wallet_view.py @@ -0,0 +1,57 @@ +import logging + +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from purchase.circle import CircleWalletService +from purchase.circle.client import CircleWalletCreationError, CircleWalletFrozenError + +logger = logging.getLogger(__name__) + + +class DepositAddressView(APIView): + """ + API endpoint to get (or lazily provision) the user's Circle deposit address. + + GET /api/wallet/deposit-address/ + + Responses: + 200: {"address": "0x...", "provisioning": false} + 500: {"detail": "Failed to provision deposit address"} + """ + + permission_classes = [IsAuthenticated] + + def dispatch(self, request, *args, **kwargs): + self.service = kwargs.pop("service", None) or CircleWalletService() + return super().dispatch(request, *args, **kwargs) + + def get(self, request: Request) -> Response: + try: + result = self.service.get_or_create_deposit_address(request.user) + return Response( + { + "address": result.address, + "provisioning": result.provisioning, + } + ) + except (CircleWalletFrozenError, CircleWalletCreationError): + logger.exception( + "Circle wallet creation failed for user %s", request.user.id + ) + return Response( + {"detail": "Failed to provision deposit address"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + except Exception: + logger.exception( + "Unexpected error provisioning deposit address for user %s", + request.user.id, + ) + return Response( + {"detail": "Failed to provision deposit address"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/src/researchhub/settings.py b/src/researchhub/settings.py index c6f8dad62d..938bf0c1cf 100644 --- a/src/researchhub/settings.py +++ b/src/researchhub/settings.py @@ -936,6 +936,11 @@ def before_send(event, hint): # Endaoment Account ID ENDAOMENT_ACCOUNT_ID = os.environ.get("ENDAOMENT_ACCOUNT_ID", keys.ENDAOMENT_ACCOUNT_ID) +# Circle Developer-Controlled Wallets +CIRCLE_API_KEY = os.environ.get("CIRCLE_API_KEY", keys.CIRCLE_API_KEY) +CIRCLE_ENTITY_SECRET = os.environ.get("CIRCLE_ENTITY_SECRET", keys.CIRCLE_ENTITY_SECRET) +CIRCLE_WALLET_SET_ID = os.environ.get("CIRCLE_WALLET_SET_ID", keys.CIRCLE_WALLET_SET_ID) + # ResearchHub Journal ID RESEARCHHUB_JOURNAL_ID = os.environ.get( "RESEARCHHUB_JOURNAL_ID", keys.RESEARCHHUB_JOURNAL_ID diff --git a/src/researchhub/urls.py b/src/researchhub/urls.py index 021dec0d9c..ddf3c6b8a7 100644 --- a/src/researchhub/urls.py +++ b/src/researchhub/urls.py @@ -43,6 +43,7 @@ from organizations.views import NonprofitFundraiseLinkViewSet, NonprofitOrgViewSet from paper.views import paper_upload_views from purchase.views import ( + DepositAddressView, EndaomentCallbackView, EndaomentConnectView, EndaomentStatusView, @@ -362,6 +363,11 @@ purchase.views.PaymentIntentView.as_view(), name="payment_intent_status_view", ), + path( + "api/wallet/deposit-address/", + DepositAddressView.as_view(), + name="deposit_address", + ), path("user_saved/", UserSavedView.as_view(), name="user_saved"), path( "api/review/availability/", diff --git a/src/user/serializers.py b/src/user/serializers.py index 9971611ee8..d84548f325 100644 --- a/src/user/serializers.py +++ b/src/user/serializers.py @@ -122,6 +122,7 @@ class AuthorSerializer(ModelSerializer): reputation_list = SerializerMethodField() total_score = SerializerMethodField() university = UniversitySerializer(required=False) + wallet = SerializerMethodField() suspended_status = SerializerMethodField() is_verified = SerializerMethodField() @@ -142,6 +143,7 @@ class Meta: "suspended_status", "total_score", "university", + "wallet", "is_verified", ] read_only_fields = [ @@ -196,6 +198,19 @@ def get_total_score(self, author): if author.author_score > 0: return author.author_score + def get_wallet(self, obj): + from purchase.serializers import WalletSerializer + + if not self.context.get("include_wallet", False): + return + + try: + if obj.user is None: + return + return WalletSerializer(obj.user.wallet).data + except Exception: + pass + def get_num_posts(self, author): user = author.user if user: diff --git a/uv.lock b/uv.lock index acdfaa92e9..2e6a5ca83c 100644 --- a/uv.lock +++ b/uv.lock @@ -497,6 +497,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "circle-configurations" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lazy-imports" }, + { name = "pycryptodome" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/ae/67172d8233b145a7faa1e8c3f5ddb7bb33e10bcf9f2f82c6f71ea4acb86c/circle_configurations-9.2.0.tar.gz", hash = "sha256:fae45dbf00cdead78e7ddab95b56906c9e337342a82c4ea06908ec7158845d84", size = 36874, upload-time = "2026-01-30T11:23:19.364Z" } + +[[package]] +name = "circle-developer-controlled-wallets" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "circle-configurations" }, + { name = "circle-web3-sdk-util" }, + { name = "lazy-imports" }, + { name = "pycryptodome" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/76/5314ce95f009f7bb0892f47eda481251d0d91f9baca42635215fab328211/circle_developer_controlled_wallets-9.2.0.tar.gz", hash = "sha256:d744db8804c36ac5622b34e6353ab272cbc175eda10237276fab65fd9ea8da34", size = 84685, upload-time = "2026-01-30T11:23:22.049Z" } + +[[package]] +name = "circle-web3-sdk-util" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycryptodome" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/5e/f288a0dfb874cdc99882e7f16c3294229ff69cb8ebd997cbbed413568987/circle_web3_sdk_util-9.2.0.tar.gz", hash = "sha256:c759fe8bd552274e1d56168c8b93479339500bfe960891690f71fd0c63d9d22c", size = 9010, upload-time = "2026-01-30T11:23:16.657Z" } + [[package]] name = "ckzg" version = "2.1.5" @@ -1563,6 +1602,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" }, ] +[[package]] +name = "lazy-imports" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/67/04432aae0c1e2729bff14e1841f4a3fb63a9e354318e66622251487760c3/lazy_imports-1.2.0.tar.gz", hash = "sha256:3c546b3c1e7c4bf62a07f897f6179d9feda6118e71ef6ecc47a339cab3d2e2d9", size = 24470, upload-time = "2025-12-28T13:51:51.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/62/60ed24fa8707f10c1c5aef94791252b820be3dd6bdfc6e2fcdb08bc8912f/lazy_imports-1.2.0-py3-none-any.whl", hash = "sha256:97134d6552e2ba16f1a278e316f05313ab73b360e848e40d593d08a5c2406fdf", size = 18681, upload-time = "2025-12-28T13:51:49.802Z" }, +] + [[package]] name = "libipld" version = "3.3.2" @@ -2589,6 +2637,7 @@ dependencies = [ { name = "celery-redbeat" }, { name = "channels" }, { name = "channels-redis" }, + { name = "circle-developer-controlled-wallets" }, { name = "cloudscraper" }, { name = "daphne" }, { name = "dj-rest-auth", extra = ["with-social"] }, @@ -2657,6 +2706,7 @@ requires-dist = [ { name = "celery-redbeat", specifier = "==2.3.3" }, { name = "channels", specifier = ">=4.1.0,<5.0.0" }, { name = "channels-redis", specifier = ">=4.2.0,<5.0.0" }, + { name = "circle-developer-controlled-wallets", specifier = ">=9.2.0,<10.0.0" }, { name = "cloudscraper", specifier = ">=1.2.60,<2.0.0" }, { name = "daphne", specifier = ">=4.1.2,<5.0.0" }, { name = "dj-rest-auth", extras = ["with-social"], specifier = ">=7.0.2,<8.0.0" },