Skip to content

Commit

Permalink
First draft of external wallet import logic
Browse files Browse the repository at this point in the history
  • Loading branch information
derek-cb committed Dec 19, 2024
1 parent 7f4780d commit 3c1cda5
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 27 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions cdp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -25,6 +26,7 @@
"Wallet",
"WalletAddress",
"WalletData",
"MnemonicSeedPhrase",
"Webhook",
"Asset",
"Transfer",
Expand Down
12 changes: 12 additions & 0 deletions cdp/mnemonic_seed_phrase.py
Original file line number Diff line number Diff line change
@@ -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
118 changes: 100 additions & 18 deletions cdp/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
75 changes: 66 additions & 9 deletions cdp/wallet_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -39,38 +49,85 @@ 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":
"""Create a WalletData class instance from the given dictionary.
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:
Expand Down

0 comments on commit 3c1cda5

Please sign in to comment.