Skip to content

Commit 912c3fb

Browse files
authored
Merge pull request #3103 from ResearchHub/circle-proposal
Lazy wallet creation
2 parents 6b077d3 + ed494e4 commit 912c3fb

File tree

21 files changed

+803
-0
lines changed

21 files changed

+803
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
"celery-redbeat==2.3.3",
1717
"channels>=4.1.0,<5.0.0",
1818
"channels-redis>=4.2.0,<5.0.0",
19+
"circle-developer-controlled-wallets>=9.2.0,<10.0.0",
1920
"cloudscraper>=1.2.60,<2.0.0",
2021
"daphne>=4.1.2,<5.0.0",
2122
"dj-rest-auth[with-social]>=7.0.2,<8.0.0",

src/config/ci/keys.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,7 @@
7676
RESEARCHHUB_JOURNAL_ID = os.environ.get("RESEARCHHUB_JOURNAL_ID", "")
7777

7878
SCRAPER_URL = os.environ.get("SCRAPER_URL", "")
79+
80+
CIRCLE_API_KEY = os.environ.get("CIRCLE_API_KEY", "")
81+
CIRCLE_ENTITY_SECRET = os.environ.get("CIRCLE_ENTITY_SECRET", "")
82+
CIRCLE_WALLET_SET_ID = os.environ.get("CIRCLE_WALLET_SET_ID", "")

src/config/keys.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,7 @@
7474
RESEARCHHUB_JOURNAL_ID = os.environ.get("RESEARCHHUB_JOURNAL_ID", "")
7575

7676
SCRAPER_URL = os.environ.get("SCRAPER_URL", "")
77+
78+
CIRCLE_API_KEY = os.environ.get("CIRCLE_API_KEY", "")
79+
CIRCLE_ENTITY_SECRET = os.environ.get("CIRCLE_ENTITY_SECRET", "")
80+
CIRCLE_WALLET_SET_ID = os.environ.get("CIRCLE_WALLET_SET_ID", "")

src/purchase/circle/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from purchase.circle.client import CircleWalletClient
2+
from purchase.circle.service import CircleWalletService
3+
4+
__all__ = [
5+
"CircleWalletClient",
6+
"CircleWalletService",
7+
]

src/purchase/circle/client.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import logging
2+
import uuid
3+
from dataclasses import dataclass
4+
5+
from circle.web3 import utils as circle_utils
6+
from circle.web3.developer_controlled_wallets.api import WalletsApi
7+
from circle.web3.developer_controlled_wallets.models import (
8+
AccountType,
9+
Blockchain,
10+
CreateWalletRequest,
11+
)
12+
from django.conf import settings
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class CircleWalletCreationError(Exception):
18+
"""Raised when Circle fails to create a wallet."""
19+
20+
pass
21+
22+
23+
class CircleWalletFrozenError(Exception):
24+
"""Raised when a Circle wallet is in FROZEN state."""
25+
26+
pass
27+
28+
29+
@dataclass
30+
class CircleWalletResult:
31+
"""Result of fetching a Circle wallet."""
32+
33+
wallet_id: str
34+
address: str
35+
state: str
36+
37+
38+
class CircleWalletClient:
39+
"""
40+
Low-level client wrapping the Circle developer-controlled wallets SDK.
41+
42+
Handles SDK initialization and provides methods for creating and
43+
fetching wallets.
44+
"""
45+
46+
def __init__(self):
47+
self._wallets_api = None
48+
49+
@property
50+
def wallets_api(self):
51+
if self._wallets_api is None:
52+
api_client = circle_utils.init_developer_controlled_wallets_client(
53+
api_key=settings.CIRCLE_API_KEY,
54+
entity_secret=settings.CIRCLE_ENTITY_SECRET,
55+
)
56+
self._wallets_api = WalletsApi(api_client)
57+
return self._wallets_api
58+
59+
def create_wallet(self, idempotency_key: str | None = None) -> str:
60+
"""
61+
Request creation of a new SCA wallet on ETH and BASE.
62+
63+
Circle wallet creation may be asynchronous. This method returns the
64+
wallet ID; the caller should use `get_wallet()` to check if the
65+
wallet is LIVE and retrieve the on-chain address.
66+
67+
Args:
68+
idempotency_key: Key to prevent duplicate wallet creation on
69+
retries. Auto-generated if not provided.
70+
71+
Returns:
72+
The Circle wallet ID (UUID string).
73+
74+
Raises:
75+
CircleWalletCreationError: If the API returns no wallets.
76+
"""
77+
if not idempotency_key:
78+
idempotency_key = uuid.uuid4().hex
79+
80+
request = CreateWalletRequest(
81+
idempotencyKey=idempotency_key,
82+
blockchains=[Blockchain.ETH, Blockchain.BASE],
83+
walletSetId=settings.CIRCLE_WALLET_SET_ID,
84+
accountType=AccountType.SCA,
85+
count=1,
86+
)
87+
88+
response = self.wallets_api.create_wallet(request)
89+
wallets = response.data.wallets
90+
91+
if not wallets:
92+
raise CircleWalletCreationError(
93+
"Circle API returned no wallets in creation response"
94+
)
95+
96+
wallet = wallets[0].actual_instance
97+
return wallet.id
98+
99+
def get_wallet(self, wallet_id: str) -> CircleWalletResult:
100+
"""
101+
Fetch the current state of a Circle wallet.
102+
103+
Args:
104+
wallet_id: The Circle wallet UUID.
105+
106+
Returns:
107+
CircleWalletResult with wallet details.
108+
"""
109+
response = self.wallets_api.get_wallet(wallet_id)
110+
wallet = response.data.wallet.actual_instance
111+
112+
return CircleWalletResult(
113+
wallet_id=wallet.id,
114+
address=wallet.address,
115+
state=wallet.state.value,
116+
)

src/purchase/circle/service.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import logging
2+
from dataclasses import dataclass
3+
from typing import Optional
4+
5+
from django.db import transaction
6+
7+
from purchase.circle.client import CircleWalletClient, CircleWalletFrozenError
8+
from purchase.models import Wallet
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
@dataclass
14+
class DepositAddressResult:
15+
"""Result of requesting a user's deposit address."""
16+
17+
address: str
18+
provisioning: bool = False
19+
20+
21+
class CircleWalletService:
22+
"""
23+
Service for lazy Circle wallet provisioning.
24+
25+
When a user requests a deposit address, this service either returns their
26+
existing Circle wallet address or provisions a new one via the Circle API.
27+
"""
28+
29+
def __init__(self, client: Optional[CircleWalletClient] = None):
30+
self.client = client or CircleWalletClient()
31+
32+
def get_or_create_deposit_address(self, user) -> DepositAddressResult:
33+
"""
34+
Get (or provision) the Circle deposit address for a user.
35+
36+
Flow:
37+
1. If the wallet already has an address with a Circle wallet_type,
38+
return it immediately.
39+
2. If user has a circle_wallet_id but no address, fetch from Circle.
40+
3. If user has neither, create a new Circle wallet and fetch.
41+
42+
Args:
43+
user: The authenticated Django User instance.
44+
45+
Returns:
46+
DepositAddressResult with the on-chain address.
47+
48+
Raises:
49+
CircleWalletFrozenError: If the wallet is not in LIVE state.
50+
CircleWalletCreationError: If Circle API fails.
51+
"""
52+
# Phase 1: Lock the wallet row and determine what action is needed.
53+
# If we need to create a Circle wallet, do it inside this transaction
54+
# so the wallet_id is persisted even if polling later fails.
55+
with transaction.atomic():
56+
wallet, _ = Wallet.objects.select_for_update().get_or_create(user=user)
57+
58+
# Already fully provisioned
59+
if wallet.circle_wallet_id and wallet.address:
60+
return DepositAddressResult(address=wallet.address)
61+
62+
# No Circle wallet yet — create one and persist the ID
63+
if not wallet.circle_wallet_id:
64+
self._create_wallet(wallet)
65+
66+
# Phase 2: Fetch the address outside the transaction so that
67+
# a CircleWalletFrozenError does NOT roll back the wallet_id save.
68+
return self._fetch_and_store_address(wallet)
69+
70+
def _create_wallet(self, wallet: Wallet) -> None:
71+
"""Create a new Circle wallet and store the wallet ID."""
72+
idempotency_key = f"rh-wallet-{wallet.pk}"
73+
wallet_id = self.client.create_wallet(idempotency_key=idempotency_key)
74+
75+
wallet.circle_wallet_id = wallet_id
76+
wallet.wallet_type = Wallet.WALLET_TYPE_CIRCLE
77+
wallet.save(update_fields=["circle_wallet_id", "wallet_type"])
78+
79+
logger.info(
80+
"Circle wallet created for wallet pk=%s, circle_wallet_id=%s",
81+
wallet.pk,
82+
wallet_id,
83+
)
84+
85+
def _fetch_and_store_address(self, wallet: Wallet) -> DepositAddressResult:
86+
"""Fetch wallet state from Circle. Store address if LIVE."""
87+
result = self.client.get_wallet(wallet.circle_wallet_id)
88+
89+
if result.state != "LIVE":
90+
raise CircleWalletFrozenError(
91+
f"Wallet {wallet.circle_wallet_id} is in state "
92+
f"'{result.state}', expected LIVE"
93+
)
94+
95+
wallet.address = result.address
96+
wallet.save(update_fields=["address"])
97+
98+
logger.info(
99+
"Circle address stored for wallet pk=%s: %s",
100+
wallet.pk,
101+
result.address,
102+
)
103+
104+
return DepositAddressResult(address=result.address)

src/purchase/circle/tests/__init__.py

Whitespace-only changes.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from unittest.mock import Mock, patch
2+
3+
from django.test import TestCase
4+
5+
from purchase.circle.client import CircleWalletClient, CircleWalletCreationError
6+
7+
8+
class TestCircleWalletClient(TestCase):
9+
"""Tests for CircleWalletClient."""
10+
11+
def _make_client(self, mock_wallets_api):
12+
"""Create a CircleWalletClient with a mocked WalletsApi."""
13+
client = CircleWalletClient.__new__(CircleWalletClient)
14+
client._wallets_api = mock_wallets_api
15+
return client
16+
17+
def _make_wallet_instance(self, **kwargs):
18+
"""Create a mock wallet actual_instance."""
19+
wallet = Mock()
20+
wallet.id = kwargs.get("id", "wallet-uuid-1")
21+
wallet.address = kwargs.get("address", "0xABC123")
22+
wallet.state = kwargs.get("state", Mock(value="LIVE"))
23+
return wallet
24+
25+
def test_create_wallet_returns_wallet_id(self):
26+
mock_api = Mock()
27+
wallet_instance = self._make_wallet_instance(id="circle-wallet-uuid-1")
28+
wallet_wrapper = Mock()
29+
wallet_wrapper.actual_instance = wallet_instance
30+
31+
mock_api.create_wallet.return_value = Mock(data=Mock(wallets=[wallet_wrapper]))
32+
33+
client = self._make_client(mock_api)
34+
wallet_id = client.create_wallet(idempotency_key="test-key-1")
35+
36+
self.assertEqual(wallet_id, "circle-wallet-uuid-1")
37+
mock_api.create_wallet.assert_called_once()
38+
39+
def test_create_wallet_empty_response_raises(self):
40+
mock_api = Mock()
41+
mock_api.create_wallet.return_value = Mock(data=Mock(wallets=[]))
42+
43+
client = self._make_client(mock_api)
44+
45+
with self.assertRaises(CircleWalletCreationError):
46+
client.create_wallet()
47+
48+
def test_create_wallet_generates_idempotency_key_when_none(self):
49+
mock_api = Mock()
50+
wallet_instance = self._make_wallet_instance()
51+
wallet_wrapper = Mock()
52+
wallet_wrapper.actual_instance = wallet_instance
53+
mock_api.create_wallet.return_value = Mock(data=Mock(wallets=[wallet_wrapper]))
54+
55+
client = self._make_client(mock_api)
56+
client.create_wallet()
57+
58+
# Verify create_wallet was called (idempotency key auto-generated)
59+
call_args = mock_api.create_wallet.call_args
60+
request = call_args[0][0]
61+
self.assertIsNotNone(request.idempotency_key)
62+
63+
def test_get_wallet_live_returns_result(self):
64+
mock_api = Mock()
65+
live_state = Mock(value="LIVE")
66+
# WalletState.LIVE comparison
67+
from circle.web3.developer_controlled_wallets.models import WalletState
68+
69+
wallet_instance = Mock()
70+
wallet_instance.id = "wallet-1"
71+
wallet_instance.address = "0xabc123"
72+
wallet_instance.state = WalletState.LIVE
73+
74+
wallet_wrapper = Mock()
75+
wallet_wrapper.actual_instance = wallet_instance
76+
mock_api.get_wallet.return_value = Mock(data=Mock(wallet=wallet_wrapper))
77+
78+
client = self._make_client(mock_api)
79+
result = client.get_wallet("wallet-1")
80+
81+
self.assertEqual(result.wallet_id, "wallet-1")
82+
self.assertEqual(result.address, "0xabc123")
83+
self.assertEqual(result.state, "LIVE")
84+
85+
def test_get_wallet_not_live_returns_result_with_state(self):
86+
mock_api = Mock()
87+
from circle.web3.developer_controlled_wallets.models import WalletState
88+
89+
wallet_instance = Mock()
90+
wallet_instance.id = "wallet-1"
91+
wallet_instance.address = ""
92+
wallet_instance.state = WalletState.FROZEN
93+
94+
wallet_wrapper = Mock()
95+
wallet_wrapper.actual_instance = wallet_instance
96+
mock_api.get_wallet.return_value = Mock(data=Mock(wallet=wallet_wrapper))
97+
98+
client = self._make_client(mock_api)
99+
result = client.get_wallet("wallet-1")
100+
101+
self.assertEqual(result.wallet_id, "wallet-1")
102+
self.assertEqual(result.address, "")
103+
self.assertEqual(result.state, "FROZEN")

0 commit comments

Comments
 (0)