Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/config/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
7 changes: 7 additions & 0 deletions src/purchase/circle/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from purchase.circle.client import CircleWalletClient
from purchase.circle.service import CircleWalletService

__all__ = [
"CircleWalletClient",
"CircleWalletService",
]
125 changes: 125 additions & 0 deletions src/purchase/circle/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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,
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):
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.

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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonder if we should put exception handling in calling code. Given the name of the function, I'd expect it to return CircleWalletResult and not raise.

f"Wallet {wallet_id} is in state '{wallet.state}', not LIVE"
)

return CircleWalletResult(
wallet_id=wallet.id,
address=wallet.address,
state=wallet.state.value,
)
108 changes: 108 additions & 0 deletions src/purchase/circle/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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 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.
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:
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_or_create(user=user)

# Already fully provisioned
if wallet.circle_wallet_id and wallet.address:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing isn't fully clear to me: How does a wallet go "LIVE" ?

Seems like a wallet can have the following states

  • wallet.circle_wallet_id exists
  • wallet.address exists
  • wallet.state is "LIVE"
  • wallet.state is not "LIVE" (not sure what other states can exist here)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it just takes time for a wallet to be deployed on Circle's side. So it will go from a pending state to a live state. The only other state I'm aware of is frozen which I think is more relevant to user controlled wallets. This is something that will become more clear when testing

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a chance it's immediately live on response which if that's the case we don't need to worry about retrial

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good

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 CircleWalletNotReadyError 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."""
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.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)
Empty file.
105 changes: 105 additions & 0 deletions src/purchase/circle/tests/test_client.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading