From 2498ae511660ff1447c44a35316a91c0c24500fa Mon Sep 17 00:00:00 2001 From: Taki Koutsomitis Date: Mon, 16 Feb 2026 14:17:32 +0000 Subject: [PATCH 01/12] Implement lazy wallet creation with circle --- pyproject.toml | 1 + src/config/keys.py | 5 + src/purchase/circle/__init__.py | 7 + src/purchase/circle/client.py | 120 +++++++++++++++++ src/purchase/circle/service.py | 107 +++++++++++++++ src/purchase/circle/tests/__init__.py | 0 src/purchase/circle/tests/test_client.py | 105 +++++++++++++++ src/purchase/circle/tests/test_service.py | 123 ++++++++++++++++++ src/purchase/migrations/0046_create_wallet.py | 53 ++++++++ src/purchase/models.py | 2 + src/purchase/related_models/wallet_model.py | 32 +++++ src/purchase/serializers/__init__.py | 1 + src/purchase/serializers/wallet_serializer.py | 10 ++ src/purchase/tests/test_circle_wallet_view.py | 88 +++++++++++++ src/purchase/views/__init__.py | 1 + src/purchase/views/circle_wallet_view.py | 68 ++++++++++ src/researchhub/settings.py | 12 ++ src/researchhub/urls.py | 6 + uv.lock | 50 +++++++ 19 files changed, 791 insertions(+) create mode 100644 src/purchase/circle/__init__.py create mode 100644 src/purchase/circle/client.py create mode 100644 src/purchase/circle/service.py create mode 100644 src/purchase/circle/tests/__init__.py create mode 100644 src/purchase/circle/tests/test_client.py create mode 100644 src/purchase/circle/tests/test_service.py create mode 100644 src/purchase/migrations/0046_create_wallet.py create mode 100644 src/purchase/related_models/wallet_model.py create mode 100644 src/purchase/serializers/wallet_serializer.py create mode 100644 src/purchase/tests/test_circle_wallet_view.py create mode 100644 src/purchase/views/circle_wallet_view.py diff --git a/pyproject.toml b/pyproject.toml index f6ec88e606..f79ac7990c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ dependencies = [ "stripe>=11.2.0,<12.0.0", "web3[tester]==7.10.0", "xdk>=0.4.5", + "circle-developer-controlled-wallets>=9.2.0,<10.0.0", ] [dependency-groups] diff --git a/src/config/keys.py b/src/config/keys.py index 1cf50b5d8f..33fd575206 100644 --- a/src/config/keys.py +++ b/src/config/keys.py @@ -74,3 +74,8 @@ 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", "") +CIRCLE_ENTITY_PUBLIC_KEY = os.environ.get("CIRCLE_ENTITY_PUBLIC_KEY", "") 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..c0003918c9 --- /dev/null +++ b/src/purchase/circle/client.py @@ -0,0 +1,120 @@ +import logging +import uuid +from dataclasses import dataclass + +from circle.web3 import developer_controlled_wallets as circle_dcw +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, + WalletState, +) +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class CircleWalletCreationError(Exception): + """Raised when Circle fails to create a wallet.""" + + pass + + +class CircleWalletNotReadyError(Exception): + """Raised when a Circle wallet exists but is not yet LIVE.""" + + 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): + 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) + + 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. + + Raises: + CircleWalletNotReadyError: If the wallet is not yet LIVE. + """ + response = self.wallets_api.get_wallet(wallet_id) + wallet = response.data.wallet.actual_instance + + if wallet.state != WalletState.LIVE: + raise CircleWalletNotReadyError( + f"Wallet {wallet_id} is in state '{wallet.state}', not LIVE" + ) + + 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..0716db26b0 --- /dev/null +++ b/src/purchase/circle/service.py @@ -0,0 +1,107 @@ +import logging +from dataclasses import dataclass +from typing import Optional + +from django.db import transaction + +from purchase.circle.client import CircleWalletClient, CircleWalletNotReadyError +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 user already has a circle_address, return it immediately. + 2. If user has a circle_wallet_id but no circle_address (prior + creation was initiated but wallet wasn't LIVE yet), poll Circle. + 3. If user has neither, create a new Circle wallet and poll. + + Args: + user: The authenticated Django User instance. + + Returns: + DepositAddressResult with the on-chain address. + + Raises: + CircleWalletNotReadyError: If the wallet is created but not yet + LIVE (caller should retry after a short delay). + 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(author=user.author_profile) + + # Already fully provisioned + if wallet.circle_address: + return DepositAddressResult(address=wallet.circle_address) + + # No Circle wallet yet — create one and persist the ID + if not wallet.circle_wallet_id: + self._create_wallet(wallet) + + # Phase 2: Poll for the address outside the transaction so that + # a CircleWalletNotReadyError does NOT roll back the wallet_id save. + return self._poll_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 _poll_and_store_address(self, wallet: Wallet) -> DepositAddressResult: + """Poll Circle for wallet state. Store address if LIVE.""" + try: + result = self.client.get_wallet(wallet.circle_wallet_id) + except CircleWalletNotReadyError: + logger.info( + "Circle wallet %s not yet LIVE for wallet pk=%s", + wallet.circle_wallet_id, + wallet.pk, + ) + raise + + wallet.circle_address = result.address + wallet.save(update_fields=["circle_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..9cacc0ef3a --- /dev/null +++ b/src/purchase/circle/tests/test_client.py @@ -0,0 +1,105 @@ +from unittest.mock import Mock, patch + +from django.test import TestCase + +from purchase.circle.client import ( + CircleWalletClient, + CircleWalletCreationError, + CircleWalletNotReadyError, +) + + +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_raises(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) + + with self.assertRaises(CircleWalletNotReadyError): + client.get_wallet("wallet-1") diff --git a/src/purchase/circle/tests/test_service.py b/src/purchase/circle/tests/test_service.py new file mode 100644 index 0000000000..ac0c85d857 --- /dev/null +++ b/src/purchase/circle/tests/test_service.py @@ -0,0 +1,123 @@ +from unittest.mock import Mock + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from purchase.circle.client import ( + CircleWalletCreationError, + CircleWalletNotReadyError, + 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") + # Wallet is auto-created by user signal + self.wallet = Wallet.objects.get(author=self.user.author_profile) + + def test_returns_existing_circle_address(self): + """When circle_address is already set, return it without calling Circle.""" + self.wallet.circle_address = "0xExistingAddress" + self.wallet.circle_wallet_id = "existing-id" + self.wallet.wallet_type = Wallet.WALLET_TYPE_CIRCLE + self.wallet.save() + + 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.""" + self.wallet.circle_wallet_id = "pending-wallet-id" + self.wallet.wallet_type = Wallet.WALLET_TYPE_CIRCLE + self.wallet.save() + + 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") + + # Verify address was persisted + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.circle_address, "0xNewAddress") + + def test_creates_wallet_when_none_exists(self): + """When no circle fields are set, create wallet and fetch address.""" + 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") + self.mock_client.create_wallet.assert_called_once_with( + idempotency_key=f"rh-wallet-{self.wallet.pk}" + ) + + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.circle_wallet_id, "new-circle-wallet-id") + self.assertEqual(self.wallet.circle_address, "0xBrandNewAddress") + self.assertEqual(self.wallet.wallet_type, Wallet.WALLET_TYPE_CIRCLE) + + def test_raises_not_ready_when_wallet_not_live(self): + """When wallet is created but not LIVE, raise error. Wallet ID is saved.""" + self.mock_client.create_wallet.return_value = "pending-wallet-id" + self.mock_client.get_wallet.side_effect = CircleWalletNotReadyError( + "Not LIVE yet" + ) + + with self.assertRaises(CircleWalletNotReadyError): + self.service.get_or_create_deposit_address(self.user) + + # Wallet ID should be saved even though address is not + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.circle_wallet_id, "pending-wallet-id") + self.assertIsNone(self.wallet.circle_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_polls_not_ready_raises_when_only_wallet_id_exists(self): + """When wallet_id exists but polling says not LIVE, raise error.""" + self.wallet.circle_wallet_id = "pending-id" + self.wallet.wallet_type = Wallet.WALLET_TYPE_CIRCLE + self.wallet.save() + + self.mock_client.get_wallet.side_effect = CircleWalletNotReadyError( + "Still pending" + ) + + with self.assertRaises(CircleWalletNotReadyError): + self.service.get_or_create_deposit_address(self.user) + + # Address should still be None + self.wallet.refresh_from_db() + self.assertIsNone(self.wallet.circle_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..27d57da2c6 --- /dev/null +++ b/src/purchase/migrations/0046_create_wallet.py @@ -0,0 +1,53 @@ +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", + ), + ), + ("eth_address", models.CharField(max_length=255, null=True)), + ("btc_address", models.CharField(max_length=255, null=True)), + ("rsc_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..cfa7ba44a2 --- /dev/null +++ b/src/purchase/related_models/wallet_model.py @@ -0,0 +1,32 @@ +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, + ) + eth_address = models.CharField(max_length=255, null=True) + btc_address = models.CharField(max_length=255, null=True) + rsc_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..1a104d1fda --- /dev/null +++ b/src/purchase/tests/test_circle_wallet_view.py @@ -0,0 +1,88 @@ +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, CircleWalletNotReadyError +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_202_when_not_ready(self): + """Test 202 when wallet is being provisioned.""" + self.service_mock.get_or_create_deposit_address.side_effect = ( + CircleWalletNotReadyError("Not LIVE") + ) + + 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_202_ACCEPTED) + self.assertEqual(response["Retry-After"], "3") + self.assertIn("retry", response.data["message"].lower()) + + 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..8f7284f868 --- /dev/null +++ b/src/purchase/views/circle_wallet_view.py @@ -0,0 +1,68 @@ +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, CircleWalletNotReadyError + +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} + 202: {"message": "Wallet is being provisioned. Please retry.", + "retry_after": 3} + 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 CircleWalletNotReadyError: + return Response( + { + "message": "Wallet is being provisioned. Please retry.", + "retry_after": 3, + }, + status=status.HTTP_202_ACCEPTED, + headers={"Retry-After": "3"}, + ) + except 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..be4631f284 100644 --- a/src/researchhub/settings.py +++ b/src/researchhub/settings.py @@ -890,6 +890,7 @@ def before_send(event, hint): ENDAOMENT_REDIRECT_URL = os.environ.get( "ENDAOMENT_REDIRECT_URL", keys.ENDAOMENT_REDIRECT_URL ) +<<<<<<< HEAD # ResearchHub's Endaoment fund IDs, indexed by chain ID. # Each chain requires its own fund because Endaoment does not support bridging. # @@ -926,6 +927,9 @@ def before_send(event, hint): keys.ENDAOMENT_RH_FUND_ID_BASE, ), } +======= +ENDAOMENT_RH_FUND_ID = os.environ.get("ENDAOMENT_RH_FUND_ID", keys.ENDAOMENT_RH_FUND_ID) +>>>>>>> c6f4ca2ae (Implement lazy wallet creation with circle) # Etherscan API Key ETHERSCAN_API_KEY = os.environ.get("ETHERSCAN_API_KEY", keys.ETHERSCAN_API_KEY) @@ -936,6 +940,14 @@ 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) +CIRCLE_ENTITY_PUBLIC_KEY = os.environ.get( + "CIRCLE_ENTITY_PUBLIC_KEY", keys.CIRCLE_ENTITY_PUBLIC_KEY +) + # 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/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" }, From 275f8c5fab161c4758b5a0f14c42b40e5184bdb6 Mon Sep 17 00:00:00 2001 From: Taki Koutsomitis Date: Mon, 16 Feb 2026 16:24:57 +0000 Subject: [PATCH 02/12] Rebase --- src/purchase/circle/service.py | 19 ++--- src/purchase/circle/tests/test_service.py | 86 +++++++++++++++-------- src/user/serializers.py | 15 ++++ 3 files changed, 80 insertions(+), 40 deletions(-) diff --git a/src/purchase/circle/service.py b/src/purchase/circle/service.py index 0716db26b0..104f857ea8 100644 --- a/src/purchase/circle/service.py +++ b/src/purchase/circle/service.py @@ -34,9 +34,10 @@ def get_or_create_deposit_address(self, user) -> DepositAddressResult: Get (or provision) the Circle deposit address for a user. Flow: - 1. If user already has a circle_address, return it immediately. - 2. If user has a circle_wallet_id but no circle_address (prior - creation was initiated but wallet wasn't LIVE yet), poll Circle. + 1. If the wallet already has an eth_address with a Circle wallet_type, + return it immediately. + 2. If user has a circle_wallet_id but no eth_address (prior creation + was initiated but wallet wasn't LIVE yet), poll Circle. 3. If user has neither, create a new Circle wallet and poll. Args: @@ -54,11 +55,11 @@ def get_or_create_deposit_address(self, user) -> DepositAddressResult: # 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(author=user.author_profile) + wallet, _ = Wallet.objects.select_for_update().get_or_create(user=user) # Already fully provisioned - if wallet.circle_address: - return DepositAddressResult(address=wallet.circle_address) + if wallet.circle_wallet_id and wallet.eth_address: + return DepositAddressResult(address=wallet.eth_address) # No Circle wallet yet — create one and persist the ID if not wallet.circle_wallet_id: @@ -84,7 +85,7 @@ def _create_wallet(self, wallet: Wallet) -> None: ) def _poll_and_store_address(self, wallet: Wallet) -> DepositAddressResult: - """Poll Circle for wallet state. Store address if LIVE.""" + """Poll Circle for wallet state. Store eth_address if LIVE.""" try: result = self.client.get_wallet(wallet.circle_wallet_id) except CircleWalletNotReadyError: @@ -95,8 +96,8 @@ def _poll_and_store_address(self, wallet: Wallet) -> DepositAddressResult: ) raise - wallet.circle_address = result.address - wallet.save(update_fields=["circle_address"]) + wallet.eth_address = result.address + wallet.save(update_fields=["eth_address"]) logger.info( "Circle address stored for wallet pk=%s: %s", diff --git a/src/purchase/circle/tests/test_service.py b/src/purchase/circle/tests/test_service.py index ac0c85d857..eebffceba7 100644 --- a/src/purchase/circle/tests/test_service.py +++ b/src/purchase/circle/tests/test_service.py @@ -21,15 +21,15 @@ def setUp(self): self.mock_client = Mock() self.service = CircleWalletService(client=self.mock_client) self.user = User.objects.create_user(username="testUser1") - # Wallet is auto-created by user signal - self.wallet = Wallet.objects.get(author=self.user.author_profile) - def test_returns_existing_circle_address(self): - """When circle_address is already set, return it without calling Circle.""" - self.wallet.circle_address = "0xExistingAddress" - self.wallet.circle_wallet_id = "existing-id" - self.wallet.wallet_type = Wallet.WALLET_TYPE_CIRCLE - self.wallet.save() + def test_returns_existing_address(self): + """When eth_address and circle_wallet_id are set, return without calling Circle.""" + wallet = Wallet.objects.create( + user=self.user, + eth_address="0xExistingAddress", + circle_wallet_id="existing-id", + wallet_type=Wallet.WALLET_TYPE_CIRCLE, + ) result = self.service.get_or_create_deposit_address(self.user) @@ -39,10 +39,12 @@ def test_returns_existing_circle_address(self): 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.""" - self.wallet.circle_wallet_id = "pending-wallet-id" - self.wallet.wallet_type = Wallet.WALLET_TYPE_CIRCLE - self.wallet.save() + """When circle_wallet_id exists but no eth_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", @@ -56,12 +58,11 @@ def test_polls_when_wallet_id_exists_but_no_address(self): self.mock_client.create_wallet.assert_not_called() self.mock_client.get_wallet.assert_called_once_with("pending-wallet-id") - # Verify address was persisted - self.wallet.refresh_from_db() - self.assertEqual(self.wallet.circle_address, "0xNewAddress") + wallet.refresh_from_db() + self.assertEqual(wallet.eth_address, "0xNewAddress") - def test_creates_wallet_when_none_exists(self): - """When no circle fields are set, create wallet and fetch address.""" + 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", @@ -72,14 +73,36 @@ def test_creates_wallet_when_none_exists(self): 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.eth_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-{self.wallet.pk}" + idempotency_key=f"rh-wallet-{wallet.pk}" ) - self.wallet.refresh_from_db() - self.assertEqual(self.wallet.circle_wallet_id, "new-circle-wallet-id") - self.assertEqual(self.wallet.circle_address, "0xBrandNewAddress") - self.assertEqual(self.wallet.wallet_type, Wallet.WALLET_TYPE_CIRCLE) + wallet.refresh_from_db() + self.assertEqual(wallet.circle_wallet_id, "new-id") + self.assertEqual(wallet.eth_address, "0xAddr") def test_raises_not_ready_when_wallet_not_live(self): """When wallet is created but not LIVE, raise error. Wallet ID is saved.""" @@ -92,9 +115,9 @@ def test_raises_not_ready_when_wallet_not_live(self): self.service.get_or_create_deposit_address(self.user) # Wallet ID should be saved even though address is not - self.wallet.refresh_from_db() - self.assertEqual(self.wallet.circle_wallet_id, "pending-wallet-id") - self.assertIsNone(self.wallet.circle_address) + wallet = Wallet.objects.get(user=self.user) + self.assertEqual(wallet.circle_wallet_id, "pending-wallet-id") + self.assertIsNone(wallet.eth_address) def test_raises_creation_error_on_api_failure(self): """When Circle API fails, raise CircleWalletCreationError.""" @@ -107,9 +130,11 @@ def test_raises_creation_error_on_api_failure(self): def test_polls_not_ready_raises_when_only_wallet_id_exists(self): """When wallet_id exists but polling says not LIVE, raise error.""" - self.wallet.circle_wallet_id = "pending-id" - self.wallet.wallet_type = Wallet.WALLET_TYPE_CIRCLE - self.wallet.save() + wallet = Wallet.objects.create( + user=self.user, + circle_wallet_id="pending-id", + wallet_type=Wallet.WALLET_TYPE_CIRCLE, + ) self.mock_client.get_wallet.side_effect = CircleWalletNotReadyError( "Still pending" @@ -118,6 +143,5 @@ def test_polls_not_ready_raises_when_only_wallet_id_exists(self): with self.assertRaises(CircleWalletNotReadyError): self.service.get_or_create_deposit_address(self.user) - # Address should still be None - self.wallet.refresh_from_db() - self.assertIsNone(self.wallet.circle_address) + wallet.refresh_from_db() + self.assertIsNone(wallet.eth_address) 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: From a058e1f5755d1f934080902f70335df44995fefa Mon Sep 17 00:00:00 2001 From: Taki Koutsomitis Date: Mon, 16 Feb 2026 16:32:21 +0000 Subject: [PATCH 03/12] Simplify wallet to single address field --- src/purchase/circle/service.py | 14 +++++++------- src/purchase/circle/tests/test_service.py | 16 ++++++++-------- src/purchase/migrations/0046_create_wallet.py | 4 +--- src/purchase/related_models/wallet_model.py | 4 +--- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/purchase/circle/service.py b/src/purchase/circle/service.py index 104f857ea8..69a376b1a2 100644 --- a/src/purchase/circle/service.py +++ b/src/purchase/circle/service.py @@ -34,9 +34,9 @@ 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 eth_address with a Circle wallet_type, + 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 eth_address (prior creation + 2. If user has a circle_wallet_id but no address (prior creation was initiated but wallet wasn't LIVE yet), poll Circle. 3. If user has neither, create a new Circle wallet and poll. @@ -58,8 +58,8 @@ def get_or_create_deposit_address(self, user) -> DepositAddressResult: wallet, _ = Wallet.objects.select_for_update().get_or_create(user=user) # Already fully provisioned - if wallet.circle_wallet_id and wallet.eth_address: - return DepositAddressResult(address=wallet.eth_address) + 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: @@ -85,7 +85,7 @@ def _create_wallet(self, wallet: Wallet) -> None: ) def _poll_and_store_address(self, wallet: Wallet) -> DepositAddressResult: - """Poll Circle for wallet state. Store eth_address if LIVE.""" + """Poll Circle for wallet state. Store address if LIVE.""" try: result = self.client.get_wallet(wallet.circle_wallet_id) except CircleWalletNotReadyError: @@ -96,8 +96,8 @@ def _poll_and_store_address(self, wallet: Wallet) -> DepositAddressResult: ) raise - wallet.eth_address = result.address - wallet.save(update_fields=["eth_address"]) + wallet.address = result.address + wallet.save(update_fields=["address"]) logger.info( "Circle address stored for wallet pk=%s: %s", diff --git a/src/purchase/circle/tests/test_service.py b/src/purchase/circle/tests/test_service.py index eebffceba7..2770213032 100644 --- a/src/purchase/circle/tests/test_service.py +++ b/src/purchase/circle/tests/test_service.py @@ -23,10 +23,10 @@ def setUp(self): self.user = User.objects.create_user(username="testUser1") def test_returns_existing_address(self): - """When eth_address and circle_wallet_id are set, return without calling Circle.""" + """When address and circle_wallet_id are set, return without calling Circle.""" wallet = Wallet.objects.create( user=self.user, - eth_address="0xExistingAddress", + address="0xExistingAddress", circle_wallet_id="existing-id", wallet_type=Wallet.WALLET_TYPE_CIRCLE, ) @@ -39,7 +39,7 @@ def test_returns_existing_address(self): 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 eth_address, poll Circle.""" + """When circle_wallet_id exists but no address, poll Circle.""" wallet = Wallet.objects.create( user=self.user, circle_wallet_id="pending-wallet-id", @@ -59,7 +59,7 @@ def test_polls_when_wallet_id_exists_but_no_address(self): self.mock_client.get_wallet.assert_called_once_with("pending-wallet-id") wallet.refresh_from_db() - self.assertEqual(wallet.eth_address, "0xNewAddress") + 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.""" @@ -79,7 +79,7 @@ def test_creates_wallet_record_and_circle_wallet_when_none_exists(self): idempotency_key=f"rh-wallet-{wallet.pk}" ) self.assertEqual(wallet.circle_wallet_id, "new-circle-wallet-id") - self.assertEqual(wallet.eth_address, "0xBrandNewAddress") + self.assertEqual(wallet.address, "0xBrandNewAddress") self.assertEqual(wallet.wallet_type, Wallet.WALLET_TYPE_CIRCLE) def test_creates_circle_wallet_when_empty_wallet_exists(self): @@ -102,7 +102,7 @@ def test_creates_circle_wallet_when_empty_wallet_exists(self): wallet.refresh_from_db() self.assertEqual(wallet.circle_wallet_id, "new-id") - self.assertEqual(wallet.eth_address, "0xAddr") + self.assertEqual(wallet.address, "0xAddr") def test_raises_not_ready_when_wallet_not_live(self): """When wallet is created but not LIVE, raise error. Wallet ID is saved.""" @@ -117,7 +117,7 @@ def test_raises_not_ready_when_wallet_not_live(self): # Wallet ID should be saved even though address is not wallet = Wallet.objects.get(user=self.user) self.assertEqual(wallet.circle_wallet_id, "pending-wallet-id") - self.assertIsNone(wallet.eth_address) + self.assertIsNone(wallet.address) def test_raises_creation_error_on_api_failure(self): """When Circle API fails, raise CircleWalletCreationError.""" @@ -144,4 +144,4 @@ def test_polls_not_ready_raises_when_only_wallet_id_exists(self): self.service.get_or_create_deposit_address(self.user) wallet.refresh_from_db() - self.assertIsNone(wallet.eth_address) + self.assertIsNone(wallet.address) diff --git a/src/purchase/migrations/0046_create_wallet.py b/src/purchase/migrations/0046_create_wallet.py index 27d57da2c6..2745b98832 100644 --- a/src/purchase/migrations/0046_create_wallet.py +++ b/src/purchase/migrations/0046_create_wallet.py @@ -23,9 +23,7 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("eth_address", models.CharField(max_length=255, null=True)), - ("btc_address", models.CharField(max_length=255, null=True)), - ("rsc_address", models.CharField(max_length=255, null=True)), + ("address", models.CharField(max_length=255, null=True)), ( "circle_wallet_id", models.CharField( diff --git a/src/purchase/related_models/wallet_model.py b/src/purchase/related_models/wallet_model.py index cfa7ba44a2..d3c45a5715 100644 --- a/src/purchase/related_models/wallet_model.py +++ b/src/purchase/related_models/wallet_model.py @@ -14,9 +14,7 @@ class Wallet(models.Model): related_name="wallet", on_delete=models.CASCADE, ) - eth_address = models.CharField(max_length=255, null=True) - btc_address = models.CharField(max_length=255, null=True) - rsc_address = models.CharField(max_length=255, null=True) + address = models.CharField(max_length=255, null=True) # Circle developer-controlled wallet fields circle_wallet_id = models.CharField( From 9477382160a32d27881c504e3114361d7687f5fa Mon Sep 17 00:00:00 2001 From: Taki Koutsomitis Date: Mon, 16 Feb 2026 17:41:28 +0000 Subject: [PATCH 04/12] Fix function name --- src/purchase/circle/service.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/purchase/circle/service.py b/src/purchase/circle/service.py index 69a376b1a2..a0e9283265 100644 --- a/src/purchase/circle/service.py +++ b/src/purchase/circle/service.py @@ -37,8 +37,8 @@ def get_or_create_deposit_address(self, user) -> DepositAddressResult: 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 (prior creation - was initiated but wallet wasn't LIVE yet), poll Circle. - 3. If user has neither, create a new Circle wallet and poll. + was initiated but wallet wasn't LIVE yet), fetch from Circle. + 3. If user has neither, create a new Circle wallet and fetch. Args: user: The authenticated Django User instance. @@ -65,9 +65,9 @@ def get_or_create_deposit_address(self, user) -> DepositAddressResult: if not wallet.circle_wallet_id: self._create_wallet(wallet) - # Phase 2: Poll for the address outside the transaction so that + # Phase 2: Fetch the address outside the transaction so that # a CircleWalletNotReadyError does NOT roll back the wallet_id save. - return self._poll_and_store_address(wallet) + return self._fetch_and_store_address(wallet) def _create_wallet(self, wallet: Wallet) -> None: """Create a new Circle wallet and store the wallet ID.""" @@ -84,8 +84,8 @@ def _create_wallet(self, wallet: Wallet) -> None: wallet_id, ) - def _poll_and_store_address(self, wallet: Wallet) -> DepositAddressResult: - """Poll Circle for wallet state. Store address if LIVE.""" + def _fetch_and_store_address(self, wallet: Wallet) -> DepositAddressResult: + """Fetch wallet state from Circle. Store address if LIVE.""" try: result = self.client.get_wallet(wallet.circle_wallet_id) except CircleWalletNotReadyError: From b62bd49c7fb17e7812339cb7b6b6c3b41199fc1b Mon Sep 17 00:00:00 2001 From: Taki Koutsomitis Date: Mon, 16 Feb 2026 21:21:19 +0000 Subject: [PATCH 05/12] Fix rebase mistake --- src/researchhub/settings.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/researchhub/settings.py b/src/researchhub/settings.py index be4631f284..b9024c8c68 100644 --- a/src/researchhub/settings.py +++ b/src/researchhub/settings.py @@ -890,7 +890,6 @@ def before_send(event, hint): ENDAOMENT_REDIRECT_URL = os.environ.get( "ENDAOMENT_REDIRECT_URL", keys.ENDAOMENT_REDIRECT_URL ) -<<<<<<< HEAD # ResearchHub's Endaoment fund IDs, indexed by chain ID. # Each chain requires its own fund because Endaoment does not support bridging. # @@ -927,9 +926,6 @@ def before_send(event, hint): keys.ENDAOMENT_RH_FUND_ID_BASE, ), } -======= -ENDAOMENT_RH_FUND_ID = os.environ.get("ENDAOMENT_RH_FUND_ID", keys.ENDAOMENT_RH_FUND_ID) ->>>>>>> c6f4ca2ae (Implement lazy wallet creation with circle) # Etherscan API Key ETHERSCAN_API_KEY = os.environ.get("ETHERSCAN_API_KEY", keys.ETHERSCAN_API_KEY) From 9c873ead974b17bb5dc2d24a69cafc2f4adb4091 Mon Sep 17 00:00:00 2001 From: Gregor Zurowski Date: Tue, 17 Feb 2026 12:03:17 +0000 Subject: [PATCH 06/12] Lazily intantiate Wallets API client --- src/purchase/circle/client.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/purchase/circle/client.py b/src/purchase/circle/client.py index c0003918c9..a8e494d444 100644 --- a/src/purchase/circle/client.py +++ b/src/purchase/circle/client.py @@ -46,11 +46,17 @@ class CircleWalletClient: """ def __init__(self): - 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) + 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: """ From f7b3dcee1096f910182015289b1d1d9517edbbb8 Mon Sep 17 00:00:00 2001 From: Gregor Zurowski Date: Tue, 17 Feb 2026 12:03:56 +0000 Subject: [PATCH 07/12] [Minor] Remove unused import --- src/purchase/circle/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/purchase/circle/client.py b/src/purchase/circle/client.py index a8e494d444..d85880eea7 100644 --- a/src/purchase/circle/client.py +++ b/src/purchase/circle/client.py @@ -2,7 +2,6 @@ import uuid from dataclasses import dataclass -from circle.web3 import developer_controlled_wallets as circle_dcw 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 ( From 96723ef3d3feaf649d829abcc20da3d73d3f23e8 Mon Sep 17 00:00:00 2001 From: Gregor Zurowski Date: Tue, 17 Feb 2026 12:05:05 +0000 Subject: [PATCH 08/12] [Minor] Order dependencies alphabetically --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f79ac7990c..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", @@ -61,7 +62,6 @@ dependencies = [ "stripe>=11.2.0,<12.0.0", "web3[tester]==7.10.0", "xdk>=0.4.5", - "circle-developer-controlled-wallets>=9.2.0,<10.0.0", ] [dependency-groups] From e4a077f6deaede471cf752540b73a030bf1829d5 Mon Sep 17 00:00:00 2001 From: Taki Koutsomitis Date: Tue, 17 Feb 2026 16:32:46 +0000 Subject: [PATCH 09/12] Move raise to caller --- src/purchase/circle/client.py | 9 --------- src/purchase/circle/service.py | 11 +++++++---- src/purchase/circle/tests/test_client.py | 16 +++++++--------- src/purchase/circle/tests/test_service.py | 12 ++++++++---- 4 files changed, 22 insertions(+), 26 deletions(-) diff --git a/src/purchase/circle/client.py b/src/purchase/circle/client.py index d85880eea7..00d214d086 100644 --- a/src/purchase/circle/client.py +++ b/src/purchase/circle/client.py @@ -8,7 +8,6 @@ AccountType, Blockchain, CreateWalletRequest, - WalletState, ) from django.conf import settings @@ -106,18 +105,10 @@ def get_wallet(self, wallet_id: str) -> CircleWalletResult: Returns: CircleWalletResult with wallet details. - - Raises: - CircleWalletNotReadyError: If the wallet is not yet LIVE. """ response = self.wallets_api.get_wallet(wallet_id) wallet = response.data.wallet.actual_instance - if wallet.state != WalletState.LIVE: - raise CircleWalletNotReadyError( - f"Wallet {wallet_id} is in state '{wallet.state}', not LIVE" - ) - return CircleWalletResult( wallet_id=wallet.id, address=wallet.address, diff --git a/src/purchase/circle/service.py b/src/purchase/circle/service.py index a0e9283265..114bde3a39 100644 --- a/src/purchase/circle/service.py +++ b/src/purchase/circle/service.py @@ -86,15 +86,18 @@ def _create_wallet(self, wallet: Wallet) -> None: def _fetch_and_store_address(self, wallet: Wallet) -> DepositAddressResult: """Fetch wallet state from Circle. Store address if LIVE.""" - try: - result = self.client.get_wallet(wallet.circle_wallet_id) - except CircleWalletNotReadyError: + result = self.client.get_wallet(wallet.circle_wallet_id) + + if result.state != "LIVE": logger.info( "Circle wallet %s not yet LIVE for wallet pk=%s", wallet.circle_wallet_id, wallet.pk, ) - raise + raise CircleWalletNotReadyError( + f"Wallet {wallet.circle_wallet_id} is in state " + f"'{result.state}', not LIVE" + ) wallet.address = result.address wallet.save(update_fields=["address"]) diff --git a/src/purchase/circle/tests/test_client.py b/src/purchase/circle/tests/test_client.py index 9cacc0ef3a..d05202c856 100644 --- a/src/purchase/circle/tests/test_client.py +++ b/src/purchase/circle/tests/test_client.py @@ -2,11 +2,7 @@ from django.test import TestCase -from purchase.circle.client import ( - CircleWalletClient, - CircleWalletCreationError, - CircleWalletNotReadyError, -) +from purchase.circle.client import CircleWalletClient, CircleWalletCreationError class TestCircleWalletClient(TestCase): @@ -15,7 +11,7 @@ class TestCircleWalletClient(TestCase): def _make_client(self, mock_wallets_api): """Create a CircleWalletClient with a mocked WalletsApi.""" client = CircleWalletClient.__new__(CircleWalletClient) - client.wallets_api = mock_wallets_api + client._wallets_api = mock_wallets_api return client def _make_wallet_instance(self, **kwargs): @@ -86,7 +82,7 @@ def test_get_wallet_live_returns_result(self): self.assertEqual(result.address, "0xabc123") self.assertEqual(result.state, "LIVE") - def test_get_wallet_not_live_raises(self): + def test_get_wallet_not_live_returns_result_with_state(self): mock_api = Mock() from circle.web3.developer_controlled_wallets.models import WalletState @@ -100,6 +96,8 @@ def test_get_wallet_not_live_raises(self): mock_api.get_wallet.return_value = Mock(data=Mock(wallet=wallet_wrapper)) client = self._make_client(mock_api) + result = client.get_wallet("wallet-1") - with self.assertRaises(CircleWalletNotReadyError): - 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 index 2770213032..12238a3c94 100644 --- a/src/purchase/circle/tests/test_service.py +++ b/src/purchase/circle/tests/test_service.py @@ -107,8 +107,10 @@ def test_creates_circle_wallet_when_empty_wallet_exists(self): def test_raises_not_ready_when_wallet_not_live(self): """When wallet is created but not LIVE, raise error. Wallet ID is saved.""" self.mock_client.create_wallet.return_value = "pending-wallet-id" - self.mock_client.get_wallet.side_effect = CircleWalletNotReadyError( - "Not LIVE yet" + self.mock_client.get_wallet.return_value = CircleWalletResult( + wallet_id="pending-wallet-id", + address="", + state="PENDING", ) with self.assertRaises(CircleWalletNotReadyError): @@ -136,8 +138,10 @@ def test_polls_not_ready_raises_when_only_wallet_id_exists(self): wallet_type=Wallet.WALLET_TYPE_CIRCLE, ) - self.mock_client.get_wallet.side_effect = CircleWalletNotReadyError( - "Still pending" + self.mock_client.get_wallet.return_value = CircleWalletResult( + wallet_id="pending-id", + address="", + state="PENDING", ) with self.assertRaises(CircleWalletNotReadyError): From 8050eb145b7c6eca9a330a3d942f10519e861f1a Mon Sep 17 00:00:00 2001 From: Taki Koutsomitis Date: Tue, 17 Feb 2026 16:41:03 +0000 Subject: [PATCH 10/12] Remove retry, unless proven otherwise --- src/purchase/circle/client.py | 4 +-- src/purchase/circle/service.py | 19 ++++-------- src/purchase/circle/tests/test_service.py | 29 +++++++++---------- src/purchase/tests/test_circle_wallet_view.py | 12 ++++---- src/purchase/views/circle_wallet_view.py | 15 ++-------- 5 files changed, 29 insertions(+), 50 deletions(-) diff --git a/src/purchase/circle/client.py b/src/purchase/circle/client.py index 00d214d086..1fbd291b9d 100644 --- a/src/purchase/circle/client.py +++ b/src/purchase/circle/client.py @@ -20,8 +20,8 @@ class CircleWalletCreationError(Exception): pass -class CircleWalletNotReadyError(Exception): - """Raised when a Circle wallet exists but is not yet LIVE.""" +class CircleWalletFrozenError(Exception): + """Raised when a Circle wallet is in FROZEN state.""" pass diff --git a/src/purchase/circle/service.py b/src/purchase/circle/service.py index 114bde3a39..07be27b6b1 100644 --- a/src/purchase/circle/service.py +++ b/src/purchase/circle/service.py @@ -4,7 +4,7 @@ from django.db import transaction -from purchase.circle.client import CircleWalletClient, CircleWalletNotReadyError +from purchase.circle.client import CircleWalletClient, CircleWalletFrozenError from purchase.models import Wallet logger = logging.getLogger(__name__) @@ -36,8 +36,7 @@ def get_or_create_deposit_address(self, user) -> DepositAddressResult: 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 (prior creation - was initiated but wallet wasn't LIVE yet), fetch from Circle. + 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: @@ -47,8 +46,7 @@ def get_or_create_deposit_address(self, user) -> DepositAddressResult: DepositAddressResult with the on-chain address. Raises: - CircleWalletNotReadyError: If the wallet is created but not yet - LIVE (caller should retry after a short delay). + 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. @@ -66,7 +64,7 @@ def get_or_create_deposit_address(self, user) -> DepositAddressResult: self._create_wallet(wallet) # Phase 2: Fetch the address outside the transaction so that - # a CircleWalletNotReadyError does NOT roll back the wallet_id save. + # a CircleWalletFrozenError does NOT roll back the wallet_id save. return self._fetch_and_store_address(wallet) def _create_wallet(self, wallet: Wallet) -> None: @@ -89,14 +87,9 @@ def _fetch_and_store_address(self, wallet: Wallet) -> DepositAddressResult: result = self.client.get_wallet(wallet.circle_wallet_id) if result.state != "LIVE": - logger.info( - "Circle wallet %s not yet LIVE for wallet pk=%s", - wallet.circle_wallet_id, - wallet.pk, - ) - raise CircleWalletNotReadyError( + raise CircleWalletFrozenError( f"Wallet {wallet.circle_wallet_id} is in state " - f"'{result.state}', not LIVE" + f"'{result.state}', expected LIVE" ) wallet.address = result.address diff --git a/src/purchase/circle/tests/test_service.py b/src/purchase/circle/tests/test_service.py index 12238a3c94..e8adc08c9b 100644 --- a/src/purchase/circle/tests/test_service.py +++ b/src/purchase/circle/tests/test_service.py @@ -5,7 +5,7 @@ from purchase.circle.client import ( CircleWalletCreationError, - CircleWalletNotReadyError, + CircleWalletFrozenError, CircleWalletResult, ) from purchase.circle.service import CircleWalletService @@ -104,21 +104,20 @@ def test_creates_circle_wallet_when_empty_wallet_exists(self): self.assertEqual(wallet.circle_wallet_id, "new-id") self.assertEqual(wallet.address, "0xAddr") - def test_raises_not_ready_when_wallet_not_live(self): - """When wallet is created but not LIVE, raise error. Wallet ID is saved.""" - self.mock_client.create_wallet.return_value = "pending-wallet-id" + 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="pending-wallet-id", + wallet_id="frozen-wallet-id", address="", - state="PENDING", + state="FROZEN", ) - with self.assertRaises(CircleWalletNotReadyError): + with self.assertRaises(CircleWalletFrozenError): self.service.get_or_create_deposit_address(self.user) - # Wallet ID should be saved even though address is not wallet = Wallet.objects.get(user=self.user) - self.assertEqual(wallet.circle_wallet_id, "pending-wallet-id") + self.assertEqual(wallet.circle_wallet_id, "frozen-wallet-id") self.assertIsNone(wallet.address) def test_raises_creation_error_on_api_failure(self): @@ -130,21 +129,21 @@ def test_raises_creation_error_on_api_failure(self): with self.assertRaises(CircleWalletCreationError): self.service.get_or_create_deposit_address(self.user) - def test_polls_not_ready_raises_when_only_wallet_id_exists(self): - """When wallet_id exists but polling says not LIVE, raise error.""" + 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="pending-id", + circle_wallet_id="frozen-id", wallet_type=Wallet.WALLET_TYPE_CIRCLE, ) self.mock_client.get_wallet.return_value = CircleWalletResult( - wallet_id="pending-id", + wallet_id="frozen-id", address="", - state="PENDING", + state="FROZEN", ) - with self.assertRaises(CircleWalletNotReadyError): + with self.assertRaises(CircleWalletFrozenError): self.service.get_or_create_deposit_address(self.user) wallet.refresh_from_db() diff --git a/src/purchase/tests/test_circle_wallet_view.py b/src/purchase/tests/test_circle_wallet_view.py index 1a104d1fda..ea21c7b929 100644 --- a/src/purchase/tests/test_circle_wallet_view.py +++ b/src/purchase/tests/test_circle_wallet_view.py @@ -5,7 +5,7 @@ from rest_framework import status from rest_framework.test import APIRequestFactory, force_authenticate -from purchase.circle.client import CircleWalletCreationError, CircleWalletNotReadyError +from purchase.circle.client import CircleWalletCreationError, CircleWalletFrozenError from purchase.circle.service import DepositAddressResult from purchase.views import DepositAddressView @@ -35,10 +35,10 @@ def test_returns_existing_address(self): self.assertEqual(response.data["address"], "0xABC123") self.assertFalse(response.data["provisioning"]) - def test_returns_202_when_not_ready(self): - """Test 202 when wallet is being provisioned.""" + def test_returns_500_when_wallet_frozen(self): + """Test 500 when wallet is frozen.""" self.service_mock.get_or_create_deposit_address.side_effect = ( - CircleWalletNotReadyError("Not LIVE") + CircleWalletFrozenError("Wallet is FROZEN") ) request = self.factory.get("/api/wallet/deposit-address/") @@ -46,9 +46,7 @@ def test_returns_202_when_not_ready(self): response = DepositAddressView.as_view()(request, service=self.service_mock) - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(response["Retry-After"], "3") - self.assertIn("retry", response.data["message"].lower()) + 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.""" diff --git a/src/purchase/views/circle_wallet_view.py b/src/purchase/views/circle_wallet_view.py index 8f7284f868..3b5a3bb7d1 100644 --- a/src/purchase/views/circle_wallet_view.py +++ b/src/purchase/views/circle_wallet_view.py @@ -7,7 +7,7 @@ from rest_framework.views import APIView from purchase.circle import CircleWalletService -from purchase.circle.client import CircleWalletCreationError, CircleWalletNotReadyError +from purchase.circle.client import CircleWalletCreationError, CircleWalletFrozenError logger = logging.getLogger(__name__) @@ -20,8 +20,6 @@ class DepositAddressView(APIView): Responses: 200: {"address": "0x...", "provisioning": false} - 202: {"message": "Wallet is being provisioned. Please retry.", - "retry_after": 3} 500: {"detail": "Failed to provision deposit address"} """ @@ -40,16 +38,7 @@ def get(self, request: Request) -> Response: "provisioning": result.provisioning, } ) - except CircleWalletNotReadyError: - return Response( - { - "message": "Wallet is being provisioned. Please retry.", - "retry_after": 3, - }, - status=status.HTTP_202_ACCEPTED, - headers={"Retry-After": "3"}, - ) - except CircleWalletCreationError: + except (CircleWalletFrozenError, CircleWalletCreationError): logger.exception( "Circle wallet creation failed for user %s", request.user.id ) From a7b9bd3f5b6eee64c01312bea0c7f7edc86c8329 Mon Sep 17 00:00:00 2001 From: Taki Koutsomitis Date: Tue, 17 Feb 2026 16:42:28 +0000 Subject: [PATCH 11/12] Add missing env vars --- src/config/ci/keys.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/config/ci/keys.py b/src/config/ci/keys.py index bfa1a3c739..07fc48e275 100644 --- a/src/config/ci/keys.py +++ b/src/config/ci/keys.py @@ -76,3 +76,8 @@ 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", "") +CIRCLE_ENTITY_PUBLIC_KEY = os.environ.get("CIRCLE_ENTITY_PUBLIC_KEY", "") From ed494e47c5df58026d2393fae3be319b15e0d864 Mon Sep 17 00:00:00 2001 From: Taki Koutsomitis Date: Wed, 18 Feb 2026 16:46:43 +0000 Subject: [PATCH 12/12] Remove unnecessary key --- src/config/ci/keys.py | 1 - src/config/keys.py | 1 - src/researchhub/settings.py | 3 --- 3 files changed, 5 deletions(-) diff --git a/src/config/ci/keys.py b/src/config/ci/keys.py index 07fc48e275..e9d34544e0 100644 --- a/src/config/ci/keys.py +++ b/src/config/ci/keys.py @@ -80,4 +80,3 @@ 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", "") -CIRCLE_ENTITY_PUBLIC_KEY = os.environ.get("CIRCLE_ENTITY_PUBLIC_KEY", "") diff --git a/src/config/keys.py b/src/config/keys.py index 33fd575206..c51519bd95 100644 --- a/src/config/keys.py +++ b/src/config/keys.py @@ -78,4 +78,3 @@ 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", "") -CIRCLE_ENTITY_PUBLIC_KEY = os.environ.get("CIRCLE_ENTITY_PUBLIC_KEY", "") diff --git a/src/researchhub/settings.py b/src/researchhub/settings.py index b9024c8c68..938bf0c1cf 100644 --- a/src/researchhub/settings.py +++ b/src/researchhub/settings.py @@ -940,9 +940,6 @@ def before_send(event, hint): 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) -CIRCLE_ENTITY_PUBLIC_KEY = os.environ.get( - "CIRCLE_ENTITY_PUBLIC_KEY", keys.CIRCLE_ENTITY_PUBLIC_KEY -) # ResearchHub Journal ID RESEARCHHUB_JOURNAL_ID = os.environ.get(