From 3c1cda5548622bb4264d952916b0a1483bb042eb Mon Sep 17 00:00:00 2001 From: Derek Ricciuto Date: Wed, 18 Dec 2024 16:13:54 -0800 Subject: [PATCH] First draft of external wallet import logic --- CHANGELOG.md | 4 ++ cdp/__init__.py | 2 + cdp/mnemonic_seed_phrase.py | 12 ++++ cdp/wallet.py | 118 ++++++++++++++++++++++++++++++------ cdp/wallet_data.py | 75 ++++++++++++++++++++--- 5 files changed, 184 insertions(+), 27 deletions(-) create mode 100644 cdp/mnemonic_seed_phrase.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bac05be..62bfe5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## Unreleased - Add `network_id` to `WalletData` so that it is saved with the seed data and surfaced via the export function +- Add ability to import external wallets into CDP via a BIP-39 mnemonic phrase, as a 1-of-1 wallet +- Add ability to import WalletData files exported by the NodeJS CDP SDK +- Deprecate `Wallet.load_seed` method in favor of `Wallet.load_seed_from_file` +- Deprecate `Wallet.saveSeed()` method in favor of `Wallet.save_seed_to_file` ### [0.12.1] - 2024-12-10 diff --git a/cdp/__init__.py b/cdp/__init__.py index 1473e30..594ba60 100644 --- a/cdp/__init__.py +++ b/cdp/__init__.py @@ -16,6 +16,7 @@ from cdp.wallet import Wallet from cdp.wallet_address import WalletAddress from cdp.wallet_data import WalletData +from cdp.mnemonic_seed_phrase import MnemonicSeedPhrase from cdp.webhook import Webhook __all__ = [ @@ -25,6 +26,7 @@ "Wallet", "WalletAddress", "WalletData", + "MnemonicSeedPhrase", "Webhook", "Asset", "Transfer", diff --git a/cdp/mnemonic_seed_phrase.py b/cdp/mnemonic_seed_phrase.py new file mode 100644 index 0000000..51babf0 --- /dev/null +++ b/cdp/mnemonic_seed_phrase.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +@dataclass +class MnemonicSeedPhrase: + """Class representing a BIP-39mnemonic seed phrase. + + Used to import external wallets into CDP as 1-of-1 wallets. + + Args: + mnemonic_phrase (str): A valid BIP-39 mnemonic phrase (12, 15, 18, 21, or 24 words). + """ + mnemonic_phrase: str \ No newline at end of file diff --git a/cdp/wallet.py b/cdp/wallet.py index 9405770..7e6a150 100644 --- a/cdp/wallet.py +++ b/cdp/wallet.py @@ -9,7 +9,7 @@ from typing import Any, Union import coincurve -from bip_utils import Bip32Slip10Secp256k1 +from bip_utils import Bip32Slip10Secp256k1, Bip39MnemonicValidator, Bip39SeedGenerator from Crypto.Cipher import AES from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec @@ -36,6 +36,7 @@ from cdp.trade import Trade from cdp.wallet_address import WalletAddress from cdp.wallet_data import WalletData +from cdp.mnemonic_seed_phrase import MnemonicSeedPhrase from cdp.webhook import Webhook @@ -118,19 +119,47 @@ def create( interval_seconds: float = 0.2, timeout_seconds: float = 20, ) -> "Wallet": - """Create a new wallet. + """Create a new wallet with a random seed. Args: - network_id (str): The network ID of the wallet. Defaults to "base-sepolia". - interval_seconds (float): The interval between checks in seconds. Defaults to 0.2. - timeout_seconds (float): The maximum time to wait for the server signer to be active. Defaults to 20. + network_id (str) - The network ID of the wallet. Defaults to "base-sepolia". + interval_seconds (float) - The interval between checks in seconds. Defaults to 0.2. + timeout_seconds (float) - The maximum time to wait for the server signer to be active. Defaults to 20. Returns: Wallet: The created wallet object. Raises: Exception: If there's an error creating the wallet. + """ + return cls.create_with_seed( + seed=None, + network_id=network_id, + interval_seconds=interval_seconds, + timeout_seconds=timeout_seconds, + ) + + @classmethod + def create_with_seed( + cls, + seed: str | None = None, + network_id: str = "base-sepolia", + interval_seconds: float = 0.2, + timeout_seconds: float = 20, + ) -> "Wallet": + """Create a new wallet with the given seed. + Args: + seed (str) - The seed to use for the wallet. If None, a random seed will be generated. + network_id (str) - The network ID of the wallet. Defaults to "base-sepolia". + interval_seconds (float) - The interval between checks in seconds. Defaults to 0.2. + timeout_seconds (float) - The maximum time to wait for the server signer to be active. Defaults to 20. + + Returns: + Wallet: The created wallet object. + + Raises: + Exception: If there's an error creating the wallet. """ create_wallet_request = CreateWalletRequest( wallet=CreateWalletRequestWallet( @@ -139,7 +168,7 @@ def create( ) model = Cdp.api_clients.wallets.create_wallet(create_wallet_request) - wallet = cls(model) + wallet = cls(model, seed) if Cdp.use_server_signer: wallet._wait_for_signer(interval_seconds, timeout_seconds) @@ -228,29 +257,47 @@ def list(cls) -> Iterator["Wallet"]: page = response.next_page @classmethod - def import_data(cls, data: WalletData) -> "Wallet": - """Import a wallet from previously exported wallet data. + def import_data(cls, data: Union[WalletData, MnemonicSeedPhrase]) -> "Wallet": + """Import a wallet from previously exported wallet data or a mnemonic seed phrase. Args: - data (WalletData): The wallet data to import. + data (Union[WalletData, MnemonicSeedPhrase]): Either: + - WalletData: The wallet data to import, containing wallet_id and seed + - MnemonicSeedPhrase: A valid BIP-39 mnemonic phrase object for importing external wallets Returns: Wallet: The imported wallet. Raises: + ValueError: If data is not a WalletData or MnemonicSeedPhrase instance. + ValueError: If the mnemonic phrase is invalid. Exception: If there's an error getting the wallet. - """ - if not isinstance(data, WalletData): - raise ValueError("Data must be a WalletData instance") + if isinstance(data, MnemonicSeedPhrase): + # Validate mnemonic phrase + if not data.mnemonic_phrase: + raise ValueError("BIP-39 mnemonic seed phrase must be provided") - model = Cdp.api_clients.wallets.get_wallet(data.wallet_id) + # Validate the mnemonic using bip_utils + if not Bip39MnemonicValidator().IsValid(data.mnemonic_phrase): + raise ValueError("Invalid BIP-39 mnemonic seed phrase") - wallet = cls(model, data.seed) + # Convert mnemonic to seed + seed_bytes = Bip39SeedGenerator(data.mnemonic_phrase).Generate() + seed = seed_bytes.hex() - wallet._set_addresses() + # Create wallet using the provided seed + wallet = cls.create_with_seed(seed=seed) + wallet._set_addresses() + return wallet - return wallet + elif isinstance(data, WalletData): + model = Cdp.api_clients.wallets.get_wallet(data.wallet_id) + wallet = cls(model, data.seed) + wallet._set_addresses() + return wallet + + raise ValueError("Data must be a WalletData or MnemonicSeedPhrase instance") def create_address(self) -> "WalletAddress": """Create a new address for the wallet. @@ -495,6 +542,24 @@ def export_data(self) -> WalletData: return WalletData(self.id, self._seed, self.network_id) def save_seed(self, file_path: str, encrypt: bool | None = False) -> None: + """[Deprecated] Use save_seed_to_file() instead. This method will be removed in a future version. + + Args: + file_path (str): The path to the file where the seed will be saved. + encrypt (Optional[bool]): Whether to encrypt the seed before saving. Defaults to False. + + Raises: + ValueError: If the wallet does not have a seed loaded. + """ + import warnings + warnings.warn( + "save_seed() is deprecated and will be removed in a future version. Use save_seed_to_file() instead.", + DeprecationWarning, + stacklevel=2, + ) + self.save_seed_to_file(file_path, encrypt) + + def save_seed_to_file(self, file_path: str, encrypt: bool | None = False) -> None: """Save the wallet seed to a file. Args: @@ -537,6 +602,23 @@ def save_seed(self, file_path: str, encrypt: bool | None = False) -> None: json.dump(existing_seeds, f, indent=4) def load_seed(self, file_path: str) -> None: + """[Deprecated] Use load_seed_from_file() instead. This method will be removed in a future version. + + Args: + file_path (str): The path to the file containing the seed data. + + Raises: + ValueError: If the file does not contain seed data for this wallet or if decryption fails. + """ + import warnings + warnings.warn( + "load_seed() is deprecated and will be removed in a future version. Use load_seed_from_file() instead.", + DeprecationWarning, + stacklevel=2, + ) + self.load_seed_from_file(file_path) + + def load_seed_from_file(self, file_path: str) -> None: """Load the wallet seed from a file. Args: @@ -685,8 +767,8 @@ def _validate_seed(self, seed: bytes) -> None: ValueError: If the seed length is invalid. """ - if len(seed) != 64: - raise ValueError("Invalid seed length") + if len(seed) != 32 and len(seed) != 64: + raise ValueError("Seed must be 32 or 64 bytes") def _derive_key(self, index: int) -> Bip32Slip10Secp256k1: """Derive a key from the master node. diff --git a/cdp/wallet_data.py b/cdp/wallet_data.py index 65649da..2f2d341 100644 --- a/cdp/wallet_data.py +++ b/cdp/wallet_data.py @@ -24,6 +24,16 @@ def wallet_id(self) -> str: """ return self._wallet_id + @property + def walletId(self) -> str | None: + """Get the ID of the wallet (camelCase alias). + + Returns: + str | None: The ID of the wallet. + + """ + return self._wallet_id + @property def seed(self) -> str: """Get the seed of the wallet. @@ -39,22 +49,41 @@ def network_id(self) -> str | None: """Get the network ID of the wallet. Returns: - str: The network ID of the wallet. + str | None: The network ID of the wallet. """ return self._network_id - def to_dict(self) -> dict[str, str]: + @property + def networkId(self) -> str | None: + """Get the network ID of the wallet (camelCase alias). + + Returns: + str | None: The network ID of the wallet. + + """ + return self._network_id + + def to_dict(self, camel_case: bool = False) -> dict[str, str]: """Convert the wallet data to a dictionary. + Args: + camel_case (bool): Whether to use camelCase keys. Defaults to False. + Returns: dict[str, str]: The dictionary representation of the wallet data. """ - result = {"wallet_id": self.wallet_id, "seed": self.seed} - if self._network_id is not None: - result["network_id"] = self.network_id - return result + if camel_case: + result = {"walletId": self.walletId, "seed": self.seed} + if self._network_id is not None: + result["networkId"] = self.networkId + return result + else: + result = {"wallet_id": self.wallet_id, "seed": self.seed} + if self._network_id is not None: + result["network_id"] = self.network_id + return result @classmethod def from_dict(cls, data: dict[str, str]) -> "WalletData": @@ -62,15 +91,43 @@ def from_dict(cls, data: dict[str, str]) -> "WalletData": Args: data (dict[str, str]): The data to create the WalletData object from. + Must contain exactly one of ('wallet_id' or 'walletId'), and a seed. + May optionally contain exactly one of ('network_id' or 'networkId'). Returns: WalletData: The wallet data. + Raises: + ValueError: + - If both 'wallet_id' and 'walletId' are present, or if neither is present. + - If both 'network_id' and 'networkId' are present, or if neither is present. """ + has_snake_case_wallet = "wallet_id" in data + has_camel_case_wallet = "walletId" in data + + if has_snake_case_wallet and has_camel_case_wallet: + raise ValueError("Data cannot contain both 'wallet_id' and 'walletId' keys") + + wallet_id = data.get("wallet_id") if has_snake_case_wallet else data.get("walletId") + if wallet_id is None: + raise ValueError("Data must contain either 'wallet_id' or 'walletId'") + + seed = data.get("seed") + if seed is None: + raise ValueError("Data must contain 'seed'") + + has_snake_case_network = "network_id" in data + has_camel_case_network = "networkId" in data + + if has_snake_case_network and has_camel_case_network: + raise ValueError("Data cannot contain both 'network_id' and 'networkId' keys") + + network_id = data.get("network_id") if has_snake_case_network else data.get("networkId") + return cls( - data["wallet_id"], - data["seed"], - data.get("network_id") + wallet_id, + seed, + network_id ) def __str__(self) -> str: