-
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
Merged
Merged
Lazy wallet creation #3103
Changes from 11 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
2498ae5
Implement lazy wallet creation with circle
koutst 275f8c5
Rebase
koutst a058e1f
Simplify wallet to single address field
koutst 9477382
Fix function name
koutst b62bd49
Fix rebase mistake
koutst 9c873ea
Lazily intantiate Wallets API client
gzurowski f7b3dce
[Minor] Remove unused import
gzurowski 96723ef
[Minor] Order dependencies alphabetically
gzurowski e4a077f
Move raise to caller
koutst 8050eb1
Remove retry, unless proven otherwise
koutst a7b9bd3
Add missing env vars
koutst ed494e4
Remove unnecessary key
koutst File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import logging | ||
| import uuid | ||
| from dataclasses import dataclass | ||
|
|
||
| from circle.web3 import utils as circle_utils | ||
| from circle.web3.developer_controlled_wallets.api import WalletsApi | ||
| from circle.web3.developer_controlled_wallets.models import ( | ||
| AccountType, | ||
| Blockchain, | ||
| CreateWalletRequest, | ||
| ) | ||
| from django.conf import settings | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class CircleWalletCreationError(Exception): | ||
| """Raised when Circle fails to create a wallet.""" | ||
|
|
||
| pass | ||
|
|
||
|
|
||
| class CircleWalletFrozenError(Exception): | ||
| """Raised when a Circle wallet is in FROZEN state.""" | ||
|
|
||
| pass | ||
|
|
||
|
|
||
| @dataclass | ||
| class CircleWalletResult: | ||
| """Result of fetching a Circle wallet.""" | ||
|
|
||
| wallet_id: str | ||
| address: str | ||
| state: str | ||
|
|
||
|
|
||
| class CircleWalletClient: | ||
| """ | ||
| Low-level client wrapping the Circle developer-controlled wallets SDK. | ||
|
|
||
| Handles SDK initialization and provides methods for creating and | ||
| fetching wallets. | ||
| """ | ||
|
|
||
| def __init__(self): | ||
| self._wallets_api = None | ||
|
|
||
| @property | ||
| def wallets_api(self): | ||
| if self._wallets_api is None: | ||
| api_client = circle_utils.init_developer_controlled_wallets_client( | ||
| api_key=settings.CIRCLE_API_KEY, | ||
| entity_secret=settings.CIRCLE_ENTITY_SECRET, | ||
| ) | ||
| self._wallets_api = WalletsApi(api_client) | ||
| return self._wallets_api | ||
|
|
||
| def create_wallet(self, idempotency_key: str | None = None) -> str: | ||
| """ | ||
| Request creation of a new SCA wallet on ETH and BASE. | ||
|
|
||
| Circle wallet creation may be asynchronous. This method returns the | ||
| wallet ID; the caller should use `get_wallet()` to check if the | ||
| wallet is LIVE and retrieve the on-chain address. | ||
|
|
||
| Args: | ||
| idempotency_key: Key to prevent duplicate wallet creation on | ||
| retries. Auto-generated if not provided. | ||
|
|
||
| Returns: | ||
| The Circle wallet ID (UUID string). | ||
|
|
||
| Raises: | ||
| CircleWalletCreationError: If the API returns no wallets. | ||
| """ | ||
| if not idempotency_key: | ||
| idempotency_key = uuid.uuid4().hex | ||
|
|
||
| request = CreateWalletRequest( | ||
| idempotencyKey=idempotency_key, | ||
| blockchains=[Blockchain.ETH, Blockchain.BASE], | ||
| walletSetId=settings.CIRCLE_WALLET_SET_ID, | ||
| accountType=AccountType.SCA, | ||
| count=1, | ||
| ) | ||
|
|
||
| response = self.wallets_api.create_wallet(request) | ||
| wallets = response.data.wallets | ||
|
|
||
| if not wallets: | ||
| raise CircleWalletCreationError( | ||
| "Circle API returned no wallets in creation response" | ||
| ) | ||
|
|
||
| wallet = wallets[0].actual_instance | ||
| return wallet.id | ||
|
|
||
| def get_wallet(self, wallet_id: str) -> CircleWalletResult: | ||
| """ | ||
| Fetch the current state of a Circle wallet. | ||
|
|
||
| Args: | ||
| wallet_id: The Circle wallet UUID. | ||
|
|
||
| Returns: | ||
| CircleWalletResult with wallet details. | ||
| """ | ||
| response = self.wallets_api.get_wallet(wallet_id) | ||
| wallet = response.data.wallet.actual_instance | ||
|
|
||
| return CircleWalletResult( | ||
| wallet_id=wallet.id, | ||
| address=wallet.address, | ||
| state=wallet.state.value, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import logging | ||
| from dataclasses import dataclass | ||
| from typing import Optional | ||
|
|
||
| from django.db import transaction | ||
|
|
||
| from purchase.circle.client import CircleWalletClient, CircleWalletFrozenError | ||
| from purchase.models import Wallet | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @dataclass | ||
| class DepositAddressResult: | ||
| """Result of requesting a user's deposit address.""" | ||
|
|
||
| address: str | ||
| provisioning: bool = False | ||
|
|
||
|
|
||
| class CircleWalletService: | ||
| """ | ||
| Service for lazy Circle wallet provisioning. | ||
|
|
||
| When a user requests a deposit address, this service either returns their | ||
| existing Circle wallet address or provisions a new one via the Circle API. | ||
| """ | ||
|
|
||
| def __init__(self, client: Optional[CircleWalletClient] = None): | ||
| self.client = client or CircleWalletClient() | ||
|
|
||
| def get_or_create_deposit_address(self, user) -> DepositAddressResult: | ||
| """ | ||
| Get (or provision) the Circle deposit address for a user. | ||
|
|
||
| Flow: | ||
| 1. If the wallet already has an address with a Circle wallet_type, | ||
| return it immediately. | ||
| 2. If user has a circle_wallet_id but no address, fetch from Circle. | ||
| 3. If user has neither, create a new Circle wallet and fetch. | ||
|
|
||
| Args: | ||
| user: The authenticated Django User instance. | ||
|
|
||
| Returns: | ||
| DepositAddressResult with the on-chain address. | ||
|
|
||
| Raises: | ||
| CircleWalletFrozenError: If the wallet is not in LIVE state. | ||
| CircleWalletCreationError: If Circle API fails. | ||
| """ | ||
| # Phase 1: Lock the wallet row and determine what action is needed. | ||
| # If we need to create a Circle wallet, do it inside this transaction | ||
| # so the wallet_id is persisted even if polling later fails. | ||
| with transaction.atomic(): | ||
| wallet, _ = Wallet.objects.select_for_update().get_or_create(user=user) | ||
|
|
||
| # Already fully provisioned | ||
| if wallet.circle_wallet_id and wallet.address: | ||
| return DepositAddressResult(address=wallet.address) | ||
|
|
||
| # No Circle wallet yet — create one and persist the ID | ||
| if not wallet.circle_wallet_id: | ||
| self._create_wallet(wallet) | ||
|
|
||
| # Phase 2: Fetch the address outside the transaction so that | ||
| # a CircleWalletFrozenError does NOT roll back the wallet_id save. | ||
| return self._fetch_and_store_address(wallet) | ||
|
|
||
| def _create_wallet(self, wallet: Wallet) -> None: | ||
| """Create a new Circle wallet and store the wallet ID.""" | ||
| idempotency_key = f"rh-wallet-{wallet.pk}" | ||
| wallet_id = self.client.create_wallet(idempotency_key=idempotency_key) | ||
|
|
||
| wallet.circle_wallet_id = wallet_id | ||
| wallet.wallet_type = Wallet.WALLET_TYPE_CIRCLE | ||
| wallet.save(update_fields=["circle_wallet_id", "wallet_type"]) | ||
|
|
||
| logger.info( | ||
| "Circle wallet created for wallet pk=%s, circle_wallet_id=%s", | ||
| wallet.pk, | ||
| wallet_id, | ||
| ) | ||
|
|
||
| def _fetch_and_store_address(self, wallet: Wallet) -> DepositAddressResult: | ||
| """Fetch wallet state from Circle. Store address if LIVE.""" | ||
| result = self.client.get_wallet(wallet.circle_wallet_id) | ||
|
|
||
| if result.state != "LIVE": | ||
| raise CircleWalletFrozenError( | ||
| f"Wallet {wallet.circle_wallet_id} is in state " | ||
| f"'{result.state}', expected LIVE" | ||
| ) | ||
|
|
||
| wallet.address = result.address | ||
| wallet.save(update_fields=["address"]) | ||
|
|
||
| logger.info( | ||
| "Circle address stored for wallet pk=%s: %s", | ||
| wallet.pk, | ||
| result.address, | ||
| ) | ||
|
|
||
| return DepositAddressResult(address=result.address) | ||
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| from unittest.mock import Mock, patch | ||
|
|
||
| from django.test import TestCase | ||
|
|
||
| from purchase.circle.client import CircleWalletClient, CircleWalletCreationError | ||
|
|
||
|
|
||
| class TestCircleWalletClient(TestCase): | ||
| """Tests for CircleWalletClient.""" | ||
|
|
||
| def _make_client(self, mock_wallets_api): | ||
| """Create a CircleWalletClient with a mocked WalletsApi.""" | ||
| client = CircleWalletClient.__new__(CircleWalletClient) | ||
| client._wallets_api = mock_wallets_api | ||
| return client | ||
|
|
||
| def _make_wallet_instance(self, **kwargs): | ||
| """Create a mock wallet actual_instance.""" | ||
| wallet = Mock() | ||
| wallet.id = kwargs.get("id", "wallet-uuid-1") | ||
| wallet.address = kwargs.get("address", "0xABC123") | ||
| wallet.state = kwargs.get("state", Mock(value="LIVE")) | ||
| return wallet | ||
|
|
||
| def test_create_wallet_returns_wallet_id(self): | ||
| mock_api = Mock() | ||
| wallet_instance = self._make_wallet_instance(id="circle-wallet-uuid-1") | ||
| wallet_wrapper = Mock() | ||
| wallet_wrapper.actual_instance = wallet_instance | ||
|
|
||
| mock_api.create_wallet.return_value = Mock(data=Mock(wallets=[wallet_wrapper])) | ||
|
|
||
| client = self._make_client(mock_api) | ||
| wallet_id = client.create_wallet(idempotency_key="test-key-1") | ||
|
|
||
| self.assertEqual(wallet_id, "circle-wallet-uuid-1") | ||
| mock_api.create_wallet.assert_called_once() | ||
|
|
||
| def test_create_wallet_empty_response_raises(self): | ||
| mock_api = Mock() | ||
| mock_api.create_wallet.return_value = Mock(data=Mock(wallets=[])) | ||
|
|
||
| client = self._make_client(mock_api) | ||
|
|
||
| with self.assertRaises(CircleWalletCreationError): | ||
| client.create_wallet() | ||
|
|
||
| def test_create_wallet_generates_idempotency_key_when_none(self): | ||
| mock_api = Mock() | ||
| wallet_instance = self._make_wallet_instance() | ||
| wallet_wrapper = Mock() | ||
| wallet_wrapper.actual_instance = wallet_instance | ||
| mock_api.create_wallet.return_value = Mock(data=Mock(wallets=[wallet_wrapper])) | ||
|
|
||
| client = self._make_client(mock_api) | ||
| client.create_wallet() | ||
|
|
||
| # Verify create_wallet was called (idempotency key auto-generated) | ||
| call_args = mock_api.create_wallet.call_args | ||
| request = call_args[0][0] | ||
| self.assertIsNotNone(request.idempotency_key) | ||
|
|
||
| def test_get_wallet_live_returns_result(self): | ||
| mock_api = Mock() | ||
| live_state = Mock(value="LIVE") | ||
| # WalletState.LIVE comparison | ||
| from circle.web3.developer_controlled_wallets.models import WalletState | ||
|
|
||
| wallet_instance = Mock() | ||
| wallet_instance.id = "wallet-1" | ||
| wallet_instance.address = "0xabc123" | ||
| wallet_instance.state = WalletState.LIVE | ||
|
|
||
| wallet_wrapper = Mock() | ||
| wallet_wrapper.actual_instance = wallet_instance | ||
| mock_api.get_wallet.return_value = Mock(data=Mock(wallet=wallet_wrapper)) | ||
|
|
||
| client = self._make_client(mock_api) | ||
| result = client.get_wallet("wallet-1") | ||
|
|
||
| self.assertEqual(result.wallet_id, "wallet-1") | ||
| self.assertEqual(result.address, "0xabc123") | ||
| self.assertEqual(result.state, "LIVE") | ||
|
|
||
| def test_get_wallet_not_live_returns_result_with_state(self): | ||
| mock_api = Mock() | ||
| from circle.web3.developer_controlled_wallets.models import WalletState | ||
|
|
||
| wallet_instance = Mock() | ||
| wallet_instance.id = "wallet-1" | ||
| wallet_instance.address = "" | ||
| wallet_instance.state = WalletState.FROZEN | ||
|
|
||
| wallet_wrapper = Mock() | ||
| wallet_wrapper.actual_instance = wallet_instance | ||
| mock_api.get_wallet.return_value = Mock(data=Mock(wallet=wallet_wrapper)) | ||
|
|
||
| client = self._make_client(mock_api) | ||
| result = client.get_wallet("wallet-1") | ||
|
|
||
| self.assertEqual(result.wallet_id, "wallet-1") | ||
| self.assertEqual(result.address, "") | ||
| self.assertEqual(result.state, "FROZEN") |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
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_idexistswallet.addressexistswallet.stateis "LIVE"wallet.stateis not "LIVE" (not sure what other states can exist here)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.
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
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.
There's a chance it's immediately live on response which if that's the case we don't need to worry about retrial
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.
Sounds good