-
Notifications
You must be signed in to change notification settings - Fork 44
Lazy wallet creation #3103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Lazy wallet creation #3103
Changes from 8 commits
2498ae5
275f8c5
a058e1f
9477382
b62bd49
9c873ea
f7b3dce
96723ef
e4a077f
8050eb1
a7b9bd3
ed494e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", | ||
| ] |
| 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( | ||
| f"Wallet {wallet_id} is in state '{wallet.state}', not LIVE" | ||
| ) | ||
|
|
||
| return CircleWalletResult( | ||
| wallet_id=wallet.id, | ||
| address=wallet.address, | ||
| state=wallet.state.value, | ||
| ) | ||
| 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
| 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") |
There was a problem hiding this comment.
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.