Skip to content
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

feat(PSDK-670): Support external wallet imports, wallet imports from CDP Python SDK #68

Merged
merged 7 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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.save_seed` method in favor of `Wallet.save_seed_to_file`

### [0.12.1] - 2024-12-10

Expand Down
26 changes: 14 additions & 12 deletions cdp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from cdp.contract_invocation import ContractInvocation
from cdp.faucet_transaction import FaucetTransaction
from cdp.hash_utils import hash_message, hash_typed_data_message
from cdp.mnemonic_seed_phrase import MnemonicSeedPhrase
from cdp.payload_signature import PayloadSignature
from cdp.smart_contract import SmartContract
from cdp.sponsored_send import SponsoredSend
Expand All @@ -19,24 +20,25 @@
from cdp.webhook import Webhook

__all__ = [
"__version__",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

lint changes

"Cdp",
"ContractInvocation",
"Wallet",
"WalletAddress",
"WalletData",
"Webhook",
"Asset",
"Transfer",
"Address",
"Transaction",
"Asset",
"Balance",
"BalanceMap",
"Cdp",
"ContractInvocation",
"FaucetTransaction",
"Trade",
"SponsoredSend",
"MnemonicSeedPhrase",
"PayloadSignature",
"SmartContract",
"SponsoredSend",
"Trade",
"Transaction",
"Transfer",
"Wallet",
"WalletAddress",
"WalletData",
"Webhook",
"__version__",
"hash_message",
"hash_typed_data_message",
]
15 changes: 15 additions & 0 deletions cdp/mnemonic_seed_phrase.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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
137 changes: 123 additions & 14 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 @@ -31,6 +31,7 @@
from cdp.faucet_transaction import FaucetTransaction
from cdp.fund_operation import FundOperation
from cdp.fund_quote import FundQuote
from cdp.mnemonic_seed_phrase import MnemonicSeedPhrase
from cdp.payload_signature import PayloadSignature
from cdp.smart_contract import SmartContract
from cdp.trade import Trade
Expand Down Expand Up @@ -118,7 +119,7 @@ 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".
Expand All @@ -131,6 +132,36 @@ def create(
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 +170,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 +259,65 @@ 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_wallet(cls, data: WalletData | MnemonicSeedPhrase) -> "Wallet":
"""Import a wallet from previously exported wallet data or a mnemonic seed phrase.
Copy link
Contributor

Choose a reason for hiding this comment

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

This is importing a wallet from previously exported wallet data or "creating a new wallet" from a mnemonic seed phrase.

It feels like we should split out the "load" from the "create" flows here? Or is the intent to lazily create the wallet (if one does not exist)? In that world we'd need to be able to check if a wallet already exists when providing the Mnemonic Seed Phrase as well?

@derek-cb @John-peterson-coinbase what are y'alls thoughts here?

Copy link
Contributor

Choose a reason for hiding this comment

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

Edit: See that this is a 2-step plan:
Move WalletData out of the import() method and into its own method (ie, load())

Seems reasonable, although I feel like import would make more sense than import_data for that behavior of taking a mnemonic seed phrase or seed hex, and creating a wallet?

Copy link
Contributor Author

@derek-cb derek-cb Dec 19, 2024

Choose a reason for hiding this comment

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

ah yes good point. while this is a two-step plan, as you mentioned it doesn't quite make sense in the context of the Python SDK since the existing method is called "import_data". i would propose:

  • renaming "import_data" to "import_wallet" ("import" is a reserved method name in Python)
  • create a new method called "import_data" for backwards compatibility (that we will remove in a future breaking release) that accepts only WalletData - not the mnemonic phrase - and simply calls "import_wallet".

@alex-stone what do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

That seems reasonable!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@alex-stone committed now


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")

@classmethod
def import_data(cls, data: WalletData) -> "Wallet":
"""Import a wallet from previously exported wallet data.

Args:
data (WalletData): The wallet data to import.

Returns:
Wallet: The imported wallet.

Raises:
ValueError: If data is not a WalletData instance.
Exception: If there's an error getting the wallet.

"""
return cls.import_wallet(data)

def create_address(self) -> "WalletAddress":
"""Create a new address for the wallet.
Expand Down Expand Up @@ -495,6 +562,28 @@ 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:
"""[Save the wallet seed to a file (deprecated).

This method is deprecated, and will be removed in a future version. Use load_seed_from_file() instead.

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 +626,26 @@ 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:
"""Load the wallet seed from a file (deprecated).

This method is deprecated, and will be removed in a future version. Use load_seed_from_file() instead.

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 +794,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
76 changes: 65 additions & 11 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,39 +49,83 @@ 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

@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) -> dict[str, str]:
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.

"""
return cls(
data["wallet_id"],
data["seed"],
data.get("network_id")
)
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(wallet_id, seed, network_id)

def __str__(self) -> str:
"""Return a string representation of the WalletData object.
Expand Down
Loading
Loading